feat: persist conversation messages across refresh

This commit is contained in:
2026-08-03 13:06:57 +08:00
parent 883767c72e
commit 328b7dd2f1
2 changed files with 228 additions and 11 deletions
+49 -11
View File
@@ -7,6 +7,7 @@ import {
type Envelope, type Envelope,
type JsonObject, type JsonObject,
} from "./protocol"; } from "./protocol";
import { ConversationStore } from "./runtime/conversation-store";
import { InteractionKernel } from "./runtime/interaction-kernel"; import { InteractionKernel } from "./runtime/interaction-kernel";
import { RendererRegistry } from "./runtime/renderer-registry"; import { RendererRegistry } from "./runtime/renderer-registry";
@@ -34,6 +35,7 @@ let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API,
let polling = false; let polling = false;
let thinking: HTMLElement | undefined; let thinking: HTMLElement | undefined;
const interactionKernel = new InteractionKernel(); const interactionKernel = new InteractionKernel();
const conversationStore = new ConversationStore(localStorage);
const app = document.querySelector<HTMLDivElement>("#app"); const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing"); 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}`; 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); } 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"; 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.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"; }); bubble.querySelectorAll("a").forEach(link => { link.target = "_blank"; link.rel = "noopener noreferrer"; });
} else bubble.textContent = content; } 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; 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 { function render(item: ConversationItem): void {
switch (item.kind) { 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 "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 "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; } 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 // DOM renderers out of App Shell; until then the registry keeps the same UI
// behavior while ensuring all incoming content traverses the Kernel first. // behavior while ensuring all incoming content traverses the Kernel first.
const rendererRegistry = new RendererRegistry<void>(); const rendererRegistry = new RendererRegistry<void>();
rendererRegistry.register("user-message", render);
rendererRegistry.register("markdown", render); rendererRegistry.register("markdown", render);
rendererRegistry.register("agent-status", render); rendererRegistry.register("agent-status", render);
rendererRegistry.register("progress", render); rendererRegistry.register("progress", render);
@@ -161,6 +168,10 @@ function renderIncoming(item: ConversationItem): void {
if (!rendererRegistry.render(item, undefined)) addSystem("收到当前客户端没有可信 Renderer 的内容。"); if (!rendererRegistry.render(item, undefined)) addSystem("收到当前客户端没有可信 Renderer 的内容。");
} }
function currentConversationKey(): string {
return `${session.uid}:${session.channelType}:${session.channelID}`;
}
function decodeBase64(value: string): string { function decodeBase64(value: string): string {
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; } 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<void> {
const previousSeq = session.lastSeq; const previousSeq = session.lastSeq;
session.lastSeq = Math.max(session.lastSeq, message.message_seq); session.lastSeq = Math.max(session.lastSeq, message.message_seq);
advanced ||= session.lastSeq > previousSeq; 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({ const result = interactionKernel.ingest({
raw: decodeBase64(message.payload), raw: payload,
source: "sync", source: "sync",
transport_id: String(message.message_seq), 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 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
if (!response.ok) throw new Error(body.error || "登录失败"); if (!response.ok) throw new Error(body.error || "登录失败");
interactionKernel.reset(); 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 }; 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); } } catch (reason) { error.textContent = loginFailureMessage(reason); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
}); });
@@ -257,13 +280,28 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim(); const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
if (!text || !session.uid) return; if (!text || !session.uid) return;
input.value = ""; send.disabled = true; input.value = ""; send.disabled = true;
const row = appendBubble("human", text); const meta = row.querySelector<HTMLElement>(".meta"); const localID = `local_${crypto.randomUUID()}`;
try { const sequence = await sendPayload(text); if (sequence) session.lastSeq = Math.max(session.lastSeq, sequence); if (meta) meta.textContent += " · 已发送"; } const createdAt = new Date().toISOString();
catch (reason) { if (meta) meta.textContent += " · 发送失败"; addSystem(reason instanceof Error ? reason.message : "发送失败"); } conversationStore.appendLocalText(localID, text, createdAt);
const row = appendBubble("human", text, false, "发送中"); const meta = row.querySelector<HTMLElement>(".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(); } finally { send.disabled = false; input.focus(); }
}); });
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); $<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
export function makeProtocolEvent( export function makeProtocolEvent(
type: string, type: string,
+179
View File
@@ -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<PersistedConversation>;
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;
}
}