feat: persist conversation messages across refresh
This commit is contained in:
+49
-11
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user