File size: 3,587 Bytes
377fc96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// Chat threads, carried over from the flux app's chat store: every thread persists in
// this browser, newest first; a send appends a user turn and a pending assistant turn,
// then the answer fills it. Offline, the question is stored for the online retrieval instead.

import { writable, get } from 'svelte/store';
import { api, enqueue } from './api';

const BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '';
const KEY = 'lifekit.chats';

export interface ChatSource {
  title: string;
  edition: string;
  volume: string;
  page: number;
  passage: string;
  snippet: string;
}
export interface ChatQueued {
  id: string;
  topic: string;
  wanted: string | null;
}
export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant';
  text: string;
  sources?: ChatSource[];
  queued?: ChatQueued | null;
  stored?: boolean;
  pending?: boolean;
}
export interface ChatThread {
  id: string;
  title: string;
  startedAt: string;
  messages: ChatMessage[];
}

let next = 0;
const id = (prefix: string) => `${prefix}${Date.now().toString(36)}_${++next}`;

function load(): ChatThread[] {
  try {
    const threads: ChatThread[] = JSON.parse(localStorage.getItem(KEY) ?? '[]');
    for (const t of threads) t.messages = t.messages.filter((m) => !m.pending);
    return threads;
  } catch {
    return [];
  }
}
function persist(threads: ChatThread[]) {
  try {
    localStorage.setItem(KEY, JSON.stringify(threads));
  } catch {
    /* storage unavailable */
  }
}

export const threads = writable<ChatThread[]>(load());
export const activeId = writable<string | null>(null);
threads.subscribe(persist);

export function open(threadId: string) {
  if (get(threads).some((t) => t.id === threadId)) activeId.set(threadId);
}
export function startNew() {
  activeId.set(null);
}

/** Send one question on the active thread, starting one when none is active. */
export async function send(question: string, online: boolean): Promise<void> {
  const trimmed = question.trim();
  if (!trimmed) return;
  let threadId = get(activeId);
  if (!threadId || !get(threads).some((t) => t.id === threadId)) {
    threadId = id('t');
    threads.update((all) => [{ id: threadId!, title: trimmed.slice(0, 80), startedAt: new Date().toISOString(), messages: [] }, ...all]);
    activeId.set(threadId);
  }
  const pendingId = id('m');
  const patch = (fn: (t: ChatThread) => ChatThread) => threads.update((all) => all.map((t) => (t.id === threadId ? fn(t) : t)));
  patch((t) => ({ ...t, messages: [...t.messages, { id: id('m'), role: 'user', text: trimmed }, { id: pendingId, role: 'assistant', text: '', pending: true }] }));
  const fill = (m: Partial<ChatMessage>) => patch((t) => ({ ...t, messages: t.messages.map((x) => (x.id === pendingId ? { ...x, ...m, pending: false } : x)) }));
  if (!online) {
    enqueue(trimmed);
    fill({ text: 'Offline. Queued until the network returns.', stored: true });
    return;
  }
  const thread = get(threads).find((t) => t.id === threadId)!;
  const history = thread.messages.filter((m) => !m.pending && m.id !== pendingId).slice(-9, -1).map((m) => ({ role: m.role, text: m.text }));
  try {
    const r = await api('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question: trimmed, history }) });
    if (!r.ok) throw new Error(`chat: ${r.status}`);
    const a = await r.json();
    fill({ text: a.text, sources: a.sources, queued: a.queued });
  } catch {
    enqueue(trimmed);
    fill({ text: 'Model unreachable. Queued until the model returns.', stored: true });
  }
}