- Empty/refusal responses retried once, then surfaced as errors instead of silent blanks
- Chinese refusal boilerplate detected via denylist + non-ASCII heuristic
- Bulk-tool preference added to SOUL.md (list_lxcs over per-entity get_lxc_state)
- Tool results truncated to 4KB on persist; get_state_snapshot filters null-state entities
- Session delete (DELETE /sessions/{id} + confirm-on-second-click UI)
- Session titles auto-generated from assistant answer instead of raw user message
189 lines
5.4 KiB
TypeScript
189 lines
5.4 KiB
TypeScript
import { writable, get } from 'svelte/store'
|
|
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
|
import type { ChatEvent, Session, Message } from '$lib/api'
|
|
|
|
export interface ChatMessage {
|
|
id: string
|
|
role: 'user' | 'assistant'
|
|
text: string
|
|
tools: ToolCallResult[]
|
|
}
|
|
|
|
export interface ToolCallResult {
|
|
type: 'tool_use' | 'tool_result'
|
|
name: string
|
|
id?: string
|
|
args?: any
|
|
result?: any
|
|
error?: string
|
|
}
|
|
|
|
function mid(): string {
|
|
return crypto.randomUUID()
|
|
}
|
|
|
|
export const messages = writable<ChatMessage[]>([])
|
|
export const streaming = writable(false)
|
|
export const currentSession = writable<string | null>(null)
|
|
export const sessions = writable<Session[]>([])
|
|
export const sessionMessages = writable<Message[]>([])
|
|
export const error = writable<string | null>(null)
|
|
|
|
let activeController: AbortController | null = null
|
|
|
|
export async function loadSessions() {
|
|
const list = await fetchSessions()
|
|
sessions.set(list)
|
|
}
|
|
|
|
// mergeToolCalls collapses a persisted tool_calls array into one entry per
|
|
// call id. Nomos persists the tool_use and tool_result as two separate
|
|
// entries sharing the same id (matching the SSE event pair); Chat.svelte
|
|
// renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate
|
|
// keys and silently aborts the whole message list. Live-streamed messages
|
|
// never hit this because sendMessage() merges tool_result into the existing
|
|
// tool_use entry in place rather than appending a second one.
|
|
function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] {
|
|
const byId = new Map<string, ToolCallResult>()
|
|
for (const tc of raw ?? []) {
|
|
const key = tc.id ?? crypto.randomUUID()
|
|
const existing = byId.get(key)
|
|
byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key })
|
|
}
|
|
return Array.from(byId.values())
|
|
}
|
|
|
|
export async function loadSessionMessages(sessionId: string) {
|
|
currentSession.set(sessionId)
|
|
const msgs = await fetchMessages(sessionId)
|
|
sessionMessages.set(msgs)
|
|
const chatMsgs: ChatMessage[] = msgs.map((m) => ({
|
|
id: m.id,
|
|
role: m.role as 'user' | 'assistant',
|
|
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
|
tools: mergeToolCalls(m.content?.tool_calls)
|
|
}))
|
|
messages.set(chatMsgs)
|
|
}
|
|
|
|
export function sendMessage(text: string) {
|
|
error.set(null)
|
|
streaming.set(true)
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: mid(),
|
|
role: 'user',
|
|
text,
|
|
tools: []
|
|
}
|
|
messages.update((ms) => [...ms, userMsg])
|
|
|
|
const assistantMsg: ChatMessage = {
|
|
id: mid(),
|
|
role: 'assistant',
|
|
text: '',
|
|
tools: []
|
|
}
|
|
messages.update((ms) => [...ms, assistantMsg])
|
|
|
|
let activeTools: Map<string, ToolCallResult> = new Map()
|
|
|
|
activeController = streamChat(
|
|
text,
|
|
get(currentSession), // continue the active session so the agent keeps context
|
|
(ev: ChatEvent) => {
|
|
if (ev.type === 'session') {
|
|
currentSession.set(ev.data)
|
|
} else if (ev.type === 'tool_use') {
|
|
const tr: ToolCallResult = {
|
|
type: 'tool_use',
|
|
name: ev.data.name,
|
|
id: ev.data.id,
|
|
args: ev.data.args
|
|
}
|
|
activeTools.set(ev.data.id, tr)
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.tools = [...last.tools, tr]
|
|
}
|
|
return [...ms]
|
|
})
|
|
} else if (ev.type === 'tool_result') {
|
|
const existing = activeTools.get(ev.data.id)
|
|
if (existing) {
|
|
const updated: ToolCallResult = {
|
|
...existing,
|
|
type: 'tool_result',
|
|
result: ev.data.result,
|
|
error: ev.data.error
|
|
}
|
|
activeTools.set(ev.data.id, updated)
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.tools = last.tools.map((t) =>
|
|
t.id === ev.data.id ? updated : t
|
|
)
|
|
}
|
|
return [...ms]
|
|
})
|
|
}
|
|
} else if (ev.type === 'text_delta') {
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.text += ev.data
|
|
}
|
|
return [...ms]
|
|
})
|
|
} else if (ev.type === 'text') {
|
|
// Final authoritative content for the turn; replaces accumulated deltas.
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.text = ev.data
|
|
}
|
|
return [...ms]
|
|
})
|
|
} else if (ev.type === 'done') {
|
|
currentSession.set(ev.data?.session_id ?? ev.session_id)
|
|
} else if (ev.type === 'error') {
|
|
error.set(ev.data)
|
|
}
|
|
},
|
|
(err: string) => {
|
|
error.set(err)
|
|
},
|
|
() => {
|
|
streaming.set(false)
|
|
activeController = null
|
|
loadSessions()
|
|
}
|
|
)
|
|
}
|
|
|
|
export function newChat() {
|
|
cancelStream()
|
|
currentSession.set(null)
|
|
messages.set([])
|
|
error.set(null)
|
|
}
|
|
|
|
export function cancelStream() {
|
|
if (activeController) {
|
|
activeController.abort()
|
|
activeController = null
|
|
streaming.set(false)
|
|
}
|
|
}
|
|
|
|
export async function deleteSession(sessionId: string) {
|
|
const ok = await apiDeleteSession(sessionId)
|
|
if (!ok) return
|
|
if (get(currentSession) === sessionId) {
|
|
newChat()
|
|
}
|
|
loadSessions()
|
|
}
|