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