From 328b7dd2f181e519953f215549aff917fd9830a9 Mon Sep 17 00:00:00 2001 From: kyugao Date: Mon, 3 Aug 2026 13:06:57 +0800 Subject: [PATCH] feat: persist conversation messages across refresh --- tauri/src/main.ts | 60 ++++++-- tauri/src/runtime/conversation-store.ts | 179 ++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 tauri/src/runtime/conversation-store.ts diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 160b4a1..9c451fd 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -7,6 +7,7 @@ import { type Envelope, type JsonObject, } from "./protocol"; +import { ConversationStore } from "./runtime/conversation-store"; import { InteractionKernel } from "./runtime/interaction-kernel"; import { RendererRegistry } from "./runtime/renderer-registry"; @@ -34,6 +35,7 @@ let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, let polling = false; let thinking: HTMLElement | undefined; const interactionKernel = new InteractionKernel(); +const conversationStore = new ConversationStore(localStorage); const app = document.querySelector("#app"); if (!app) throw new Error("LineUp host root is missing"); @@ -111,7 +113,7 @@ async function fetchLogin(endpoint: string, phone: string, code: string): Promis } } -function appendBubble(role: "human" | "agent", content: string, markdown = false): HTMLElement { +function appendBubble(role: "human" | "agent", content: string, markdown = false, delivery = ""): HTMLElement { const row = document.createElement("article"); row.className = `message-row ${role}`; if (role === "agent") { const avatar = document.createElement("span"); avatar.className = "message-avatar"; avatar.textContent = "✦"; row.append(avatar); } const bubble = document.createElement("div"); bubble.className = "bubble"; @@ -120,7 +122,7 @@ function appendBubble(role: "human" | "agent", content: string, markdown = false bubble.innerHTML = DOMPurify.sanitize(marked.parse(content, { gfm: true, breaks: true }) as string, { FORBID_TAGS: ["style", "form", "input", "button"] }); bubble.querySelectorAll("a").forEach(link => { link.target = "_blank"; link.rel = "noopener noreferrer"; }); } else bubble.textContent = content; - const meta = document.createElement("small"); meta.className = "meta"; meta.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + const meta = document.createElement("small"); meta.className = "meta"; meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`; bubble.append(meta); row.append(bubble); messages.append(row); scrollLatest(); return row; } @@ -134,7 +136,11 @@ function hideThinking(): void { thinking?.remove(); thinking = undefined; } function render(item: ConversationItem): void { switch (item.kind) { - case "user-message": appendBubble("human", item.text); break; + case "user-message": { + const delivery = item.delivery.status === "local_pending" ? "发送中" : item.delivery.status === "failed" ? "发送失败" : "已发送"; + appendBubble("human", item.text, false, delivery); + break; + } case "markdown": hideThinking(); appendBubble("agent", item.markdown, true); break; case "agent-status": item.status === "thinking" ? showThinking(item.detail || "正在思考…") : (hideThinking(), setPresence(item.detail || item.status || "在线")); break; case "progress": { const percent = Math.max(0, Math.min(100, item.percent)); addSystem(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`); break; } @@ -149,6 +155,7 @@ function render(item: ConversationItem): void { // DOM renderers out of App Shell; until then the registry keeps the same UI // behavior while ensuring all incoming content traverses the Kernel first. const rendererRegistry = new RendererRegistry(); +rendererRegistry.register("user-message", render); rendererRegistry.register("markdown", render); rendererRegistry.register("agent-status", render); rendererRegistry.register("progress", render); @@ -161,6 +168,10 @@ function renderIncoming(item: ConversationItem): void { if (!rendererRegistry.render(item, undefined)) addSystem("收到当前客户端没有可信 Renderer 的内容。"); } +function currentConversationKey(): string { + return `${session.uid}:${session.channelType}:${session.channelID}`; +} + function decodeBase64(value: string): string { try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; } } @@ -198,13 +209,20 @@ async function syncLoop(): Promise { const previousSeq = session.lastSeq; session.lastSeq = Math.max(session.lastSeq, message.message_seq); advanced ||= session.lastSeq > previousSeq; - if (message.from_uid !== session.uid) { + conversationStore.advanceCursor(message.message_seq); + const payload = decodeBase64(message.payload); + if (message.from_uid === session.uid) { + const restored = conversationStore.recordSyncedUserText(message.message_seq, payload, new Date().toISOString()); + if (restored) renderIncoming(restored.item); + } else { const result = interactionKernel.ingest({ - raw: decodeBase64(message.payload), + raw: payload, source: "sync", transport_id: String(message.message_seq), }); - for (const item of result.items) renderIncoming(item); + for (const item of result.items) { + if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item); + } } } @@ -247,7 +265,12 @@ $("#login-form").addEventListener("submit", async event => { if (!response.ok) throw new Error(body.error || "登录失败"); interactionKernel.reset(); session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true }; - loginView.hidden = true; chatView.hidden = false; addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop(); + const snapshot = conversationStore.open(currentConversationKey()); + session.lastSeq = snapshot.cursor; + messages.replaceChildren(); + loginView.hidden = true; chatView.hidden = false; + for (const stored of snapshot.items) renderIncoming(stored.item); + addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop(); } catch (reason) { error.textContent = loginFailureMessage(reason); } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } }); @@ -257,13 +280,28 @@ $("#message-form").addEventListener("submit", async event => { const input = $("#message-input"); const send = $("#send"); const text = input.value.trim(); if (!text || !session.uid) return; input.value = ""; send.disabled = true; - const row = appendBubble("human", text); const meta = row.querySelector(".meta"); - try { const sequence = await sendPayload(text); if (sequence) session.lastSeq = Math.max(session.lastSeq, sequence); if (meta) meta.textContent += " · 已发送"; } - catch (reason) { if (meta) meta.textContent += " · 发送失败"; addSystem(reason instanceof Error ? reason.message : "发送失败"); } + const localID = `local_${crypto.randomUUID()}`; + const createdAt = new Date().toISOString(); + conversationStore.appendLocalText(localID, text, createdAt); + const row = appendBubble("human", text, false, "发送中"); const meta = row.querySelector(".meta"); + try { + const sequence = await sendPayload(text); + if (sequence) { + session.lastSeq = Math.max(session.lastSeq, sequence); + conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); + } + if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送"`; + } + catch (reason) { + const message = reason instanceof Error ? reason.message : "发送失败"; + conversationStore.markFailed(localID, message, new Date().toISOString()); + if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败"`; + addSystem(message); + } finally { send.disabled = false; input.focus(); } }); -$("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); +$("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); export function makeProtocolEvent( type: string, diff --git a/tauri/src/runtime/conversation-store.ts b/tauri/src/runtime/conversation-store.ts new file mode 100644 index 0000000..79eca90 --- /dev/null +++ b/tauri/src/runtime/conversation-store.ts @@ -0,0 +1,179 @@ +import type { ConversationItem, DeliveryState } from "../protocol"; + +export type StoredConversationItem = { + local_id: string; + item: ConversationItem; + created_at: string; + message_seq?: number; +}; + +export type OutboxEntry = { + local_id: string; + payload: string; + created_at: string; +}; + +export type ConversationSnapshot = { + conversation_id: string; + cursor: number; + items: readonly StoredConversationItem[]; + outbox: readonly OutboxEntry[]; +}; + +type PersistedConversation = { + version: 1; + cursor: number; + items: StoredConversationItem[]; + outbox: OutboxEntry[]; +}; + +const STORE_PREFIX = "lineup.conversation.v1"; + +/** + * Conversation-local state and its browser persistence adapter. + * + * It stores only validated ConversationItems and outgoing plaintext awaiting + * transport acknowledgement. It has no DOM, renderer, HTTP, or IM dependency. + * A native Store can implement the same snapshot semantics in M4 hosts. + */ +export class ConversationStore { + private activeConversationID: string | undefined; + private state: PersistedConversation = { version: 1, cursor: 0, items: [], outbox: [] }; + + public constructor(private readonly storage: Storage) {} + + public open(conversationID: string): ConversationSnapshot { + this.activeConversationID = conversationID; + this.state = this.load(conversationID); + return this.snapshot(); + } + + public close(): void { + this.activeConversationID = undefined; + this.state = { version: 1, cursor: 0, items: [], outbox: [] }; + } + + public snapshot(): ConversationSnapshot { + return { + conversation_id: this.requireActiveConversationID(), + cursor: this.state.cursor, + items: this.state.items.map(item => ({ ...item })), + outbox: this.state.outbox.map(entry => ({ ...entry })), + }; + } + + public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem { + const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; + const stored: StoredConversationItem = { + local_id: localID, + created_at: createdAt, + item: { kind: "user-message", id: localID, text, delivery }, + }; + this.state.items.push(stored); + this.state.outbox.push({ local_id: localID, payload: text, created_at: createdAt }); + this.persist(); + return { ...stored }; + } + + /** Transport acknowledgement gives the local message its durable IM sequence. */ + public markSubmitted(localID: string, messageSeq: number, updatedAt: string): void { + const stored = this.state.items.find(item => item.local_id === localID); + if (!stored || stored.item.kind !== "user-message") return; + stored.message_seq = messageSeq; + stored.item = { + ...stored.item, + delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) }, + }; + this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID); + this.state.cursor = Math.max(this.state.cursor, messageSeq); + this.persist(); + } + + public markFailed(localID: string, error: string, updatedAt: string): void { + const stored = this.state.items.find(item => item.local_id === localID); + if (!stored || stored.item.kind !== "user-message") return; + stored.item = { + ...stored.item, + delivery: { status: "failed", updated_at: updatedAt, error, retryable: true }, + }; + this.persist(); + } + + public advanceCursor(messageSeq: number): void { + if (messageSeq <= this.state.cursor) return; + this.state.cursor = messageSeq; + this.persist(); + } + + /** + * Inserts a remote item exactly once. Sequence is the primary IM identity; + * an envelope id catches duplicate messages arriving through another source. + */ + public recordIncoming(item: ConversationItem, messageSeq: number, createdAt: string): boolean { + if (this.state.items.some(existing => existing.message_seq === messageSeq)) { + this.state.cursor = Math.max(this.state.cursor, messageSeq); + this.persist(); + return false; + } + if (item.id && this.state.items.some(existing => existing.item.id === item.id)) { + this.state.cursor = Math.max(this.state.cursor, messageSeq); + this.persist(); + return false; + } + this.state.items.push({ + local_id: `remote_${messageSeq}`, + item, + created_at: createdAt, + message_seq: messageSeq, + }); + this.state.cursor = Math.max(this.state.cursor, messageSeq); + this.persist(); + return true; + } + + /** Restores a user message sent by this client when no local record exists. */ + public recordSyncedUserText(messageSeq: number, text: string, createdAt: string): StoredConversationItem | undefined { + if (this.state.items.some(existing => existing.message_seq === messageSeq)) { + this.state.cursor = Math.max(this.state.cursor, messageSeq); + this.persist(); + return undefined; + } + const item: ConversationItem = { + kind: "user-message", + id: `remote_${messageSeq}`, + text, + delivery: { status: "delivered", updated_at: createdAt, transport_id: String(messageSeq) }, + }; + this.state.items.push({ local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq }); + this.state.cursor = Math.max(this.state.cursor, messageSeq); + this.persist(); + return { local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq }; + } + + private load(conversationID: string): PersistedConversation { + try { + const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 1, cursor: 0, items: [], outbox: [] }; + const candidate = parsed as Partial; + if (candidate.version !== 1 || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { + return { version: 1, cursor: 0, items: [], outbox: [] }; + } + return { version: 1, cursor: Math.max(0, candidate.cursor), items: candidate.items, outbox: candidate.outbox }; + } catch { + return { version: 1, cursor: 0, items: [], outbox: [] }; + } + } + + private persist(): void { + this.storage.setItem(this.key(this.requireActiveConversationID()), JSON.stringify(this.state)); + } + + private key(conversationID: string): string { + return `${STORE_PREFIX}:${conversationID}`; + } + + private requireActiveConversationID(): string { + if (!this.activeConversationID) throw new Error("ConversationStore has no active conversation."); + return this.activeConversationID; + } +}