Files
windmill/chat-sdk/src/utils.ts
T
GuilhemandClaude Fable 5.1 4eab995cf7 feat: tell test flow conversations from deployed ones and rename a chat (#11179)
* 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>
2026-09-17 11:41:49 +02:00

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)
}