Files
windmill/chat-sdk/src/history.ts
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

98 lines
3.0 KiB
TypeScript

import type { ChatMessage, Conversation, StorageLike } from './types'
export interface LocalHistory {
listConversations(): Conversation[]
getMessages(conversationId: string): ChatMessage[]
upsertConversation(conversation: Conversation): void
/** Changes a stored conversation's title in place; unlike `upsertConversation`, its position is kept. */
renameConversation(conversationId: string, title: string): void
saveMessages(conversationId: string, messages: ChatMessage[]): void
deleteConversation(conversationId: string): void
}
interface Snapshot {
v: 1
conversations: Conversation[]
messages: Record<string, ChatMessage[]>
}
const MAX_CONVERSATIONS = 100
/**
* Conversation history in the browser, for a credential shared by every visitor
* (server history would show them each other's chats). One key per flow, and per
* `storageKey` when the caller sets one; every operation re-reads the store so
* several tabs stay consistent.
*/
export function createLocalHistory(storage: StorageLike | undefined, key: string): LocalHistory {
const store = storage ?? defaultStorage()
const read = (): Snapshot => {
try {
const raw = store.getItem(key)
if (raw) {
const parsed = JSON.parse(raw) as Snapshot
if (parsed && parsed.v === 1) return parsed
}
} catch {
// unreadable: start over
}
return { v: 1, conversations: [], messages: {} }
}
const write = (snapshot: Snapshot) => {
try {
store.setItem(key, JSON.stringify(snapshot))
} catch {
// quota or private mode: the page keeps working from memory
}
}
return {
listConversations: () => read().conversations,
getMessages: (id) => read().messages[id] ?? [],
upsertConversation(conversation) {
const s = read()
const rest = s.conversations.filter((c) => c.id !== conversation.id)
s.conversations = [conversation, ...rest]
for (const dropped of s.conversations.splice(MAX_CONVERSATIONS)) {
delete s.messages[dropped.id]
}
write(s)
},
renameConversation(id, title) {
const s = read()
s.conversations = s.conversations.map((c) => (c.id === id ? { ...c, title } : c))
write(s)
},
saveMessages(id, messages) {
const s = read()
s.messages[id] = messages.map((m) => ({ ...m, pending: false }))
write(s)
},
deleteConversation(id) {
const s = read()
s.conversations = s.conversations.filter((c) => c.id !== id)
delete s.messages[id]
write(s)
}
}
}
function defaultStorage(): StorageLike {
try {
const ls = globalThis.localStorage
if (ls) {
const probe = '__windmill_chat_probe__'
ls.setItem(probe, '1')
ls.removeItem(probe)
return ls
}
} catch {
// no localStorage (SSR, blocked storage): fall through
}
const memory = new Map<string, string>()
return {
getItem: (k) => memory.get(k) ?? null,
setItem: (k, v) => void memory.set(k, v),
removeItem: (k) => void memory.delete(k)
}
}