mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
* feat: mark test flow conversations apart from deployed ones and allow renaming a chat Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: keep the conversation kind across refreshes and reject NUL titles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: ignore conversation lists for a kind no longer selected Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: start a fresh conversation listing when the kind changes on a later page Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: refuse sending into a conversation of the other kind Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: refuse cross-kind conversation continuations on the server Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the conversation filter unavailable while an answer runs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
export function randomId(): string {
|
|
const c = globalThis.crypto
|
|
if (c?.randomUUID) return c.randomUUID()
|
|
// `randomUUID` needs a secure context; a plain http dev origin has `getRandomValues` only.
|
|
const bytes = new Uint8Array(16)
|
|
c.getRandomValues(bytes)
|
|
return formatUuid(bytes, 4)
|
|
}
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
|
|
export function isUuid(value: string): boolean {
|
|
return UUID_RE.test(value)
|
|
}
|
|
|
|
/**
|
|
* The Windmill conversation id for an arbitrary chat id. A UUID is used as is;
|
|
* anything else (an AI SDK chat id, for instance) maps to the same UUID every time,
|
|
* so a page can reopen its conversation without storing a second id. Hashed in plain
|
|
* JS: `crypto.subtle` only exists in secure contexts, and the mapping must not depend
|
|
* on the origin's scheme.
|
|
*/
|
|
export function conversationIdFor(chatId: string): string {
|
|
if (isUuid(chatId)) return chatId.toLowerCase()
|
|
const bytes = new TextEncoder().encode(`windmill-chat:${chatId}`)
|
|
const out = new Uint8Array(16)
|
|
for (const [i, seed] of [0xcbf29ce484222325n, 0x84222325cbf29ce4n].entries()) {
|
|
let h = fnv1a64(bytes, seed)
|
|
for (let b = 7; b >= 0; b--) {
|
|
out[i * 8 + b] = Number(h & 0xffn)
|
|
h >>= 8n
|
|
}
|
|
}
|
|
return formatUuid(out, 5)
|
|
}
|
|
|
|
function fnv1a64(bytes: Uint8Array, seed: bigint): bigint {
|
|
let h = seed
|
|
for (const byte of bytes) {
|
|
h ^= BigInt(byte)
|
|
h = (h * 0x100000001b3n) & 0xffffffffffffffffn
|
|
}
|
|
return h
|
|
}
|
|
|
|
function formatUuid(bytes: Uint8Array, version: 4 | 5): string {
|
|
bytes[6] = (bytes[6] & 0x0f) | (version << 4)
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
}
|
|
|
|
export function parseJsonOr(text: string | undefined): unknown {
|
|
if (text === undefined) return undefined
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return text
|
|
}
|
|
}
|
|
|
|
export function now(): string {
|
|
return new Date().toISOString()
|
|
}
|
|
|
|
/** Same rule as the server: the first message, cut to 25 characters. */
|
|
export function conversationTitle(firstMessage: string): string {
|
|
const chars = Array.from(firstMessage)
|
|
return chars.length > 25 ? `${chars.slice(0, 25).join('')}...` : firstMessage
|
|
}
|
|
|
|
/** The server's bound on a typed title: 252 characters plus an ellipsis fits its 255-char column. */
|
|
export function truncateTitle(title: string): string {
|
|
const chars = Array.from(title)
|
|
return chars.length > 252 ? `${chars.slice(0, 252).join('')}...` : title
|
|
}
|
|
|
|
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal?.aborted) return reject(abortError())
|
|
const onAbort = () => {
|
|
clearTimeout(timer)
|
|
reject(abortError())
|
|
}
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
resolve()
|
|
}, ms)
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
export function abortError(): Error {
|
|
return new DOMException('The operation was aborted', 'AbortError')
|
|
}
|
|
|
|
export function isAbortError(e: unknown): boolean {
|
|
return e instanceof Error && e.name === 'AbortError'
|
|
}
|
|
|
|
/**
|
|
* The text a chat shows for a flow result, following what Windmill persists as the
|
|
* assistant message when the last step is not an AI agent: `windmill_chat_answer`
|
|
* when the result carries one (null means no message), an agent result's `output`,
|
|
* a string as is, anything else as JSON.
|
|
*/
|
|
export function extractChatAnswer(result: unknown): string | undefined {
|
|
if (result === null || result === undefined) return undefined
|
|
if (typeof result === 'string') return result
|
|
if (typeof result === 'object' && !Array.isArray(result)) {
|
|
const obj = result as Record<string, unknown>
|
|
if ('windmill_chat_answer' in obj) return formatAnswer(obj.windmill_chat_answer)
|
|
if ('output' in obj && Array.isArray(obj.messages)) return formatAnswer(obj.output)
|
|
}
|
|
return JSON.stringify(result, null, 2)
|
|
}
|
|
|
|
function formatAnswer(value: unknown): string | undefined {
|
|
if (value === null || value === undefined) return undefined
|
|
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
|
}
|
|
|
|
/** A completed job whose result is Windmill's error envelope. */
|
|
export function isErrorResult(result: unknown): result is { error: Record<string, unknown> } {
|
|
return (
|
|
typeof result === 'object' &&
|
|
result !== null &&
|
|
'error' in result &&
|
|
typeof (result as { error: unknown }).error === 'object' &&
|
|
(result as { error: unknown }).error !== null
|
|
)
|
|
}
|
|
|
|
export function errorResultMessage(result: { error: Record<string, unknown> }): string {
|
|
const { message, name } = result.error
|
|
if (typeof message === 'string' && message) {
|
|
return typeof name === 'string' && name && name !== 'Error' ? `${name}: ${message}` : message
|
|
}
|
|
return JSON.stringify(result.error, null, 2)
|
|
}
|