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 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<HTMLDivElement>("#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<void>();
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<void> {
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 @@ $<HTMLFormElement>("#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 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#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<HTMLElement>(".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<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(); }
});
$<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(
type: string,