import DOMPurify from "dompurify"; import { marked } from "marked"; import "./style.css"; import { type Actor, type ConversationItem, type Envelope, type JsonObject, } from "./protocol"; import { ConversationStore } from "./runtime/conversation-store"; import { InteractionKernel } from "./runtime/interaction-kernel"; import { RendererRegistry } from "./runtime/renderer-registry"; type Session = { api: string; uid: string; agentUID: string; channelID: string; channelType: number; lastSeq: number; active: boolean; }; type LoginResponse = { uid: string; agent_uid?: string; channel_id?: string; channel_type?: number; }; type SyncedMessage = { message_seq: number; from_uid: string; payload: string }; const DEFAULT_API = "http://127.0.0.1:8090"; let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false }; 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"); app.innerHTML = `
`; const $ = (selector: string): T => { const found = document.querySelector(selector); if (!found) throw new Error(`Missing element: ${selector}`); return found; }; const messages = $("#messages"); const loginView = $("#login-view"); const chatView = $("#chat-view"); const presence = $("#presence"); $("#api").value = session.api; function api(path: string): string { return `${session.api.replace(/\/$/, "")}${path}`; } function scrollLatest(): void { messages.scrollTop = messages.scrollHeight; } function setPresence(value: string): void { presence.textContent = value; } function addSystem(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; messages.append(item); scrollLatest(); } function newLocalID(prefix: string): string { // `crypto.randomUUID` is absent from older Safari / WKWebView versions. // A local item id needs uniqueness within this browser store, not a // transport identity, so the timestamp-and-random fallback is sufficient // and must not prevent the form submit handler from reaching HTTP. const uuid = globalThis.crypto?.randomUUID?.(); return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`; } function storageFailureMessage(reason: unknown): string { const detail = reason instanceof Error && reason.message ? `(${reason.message})` : ""; return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`; } function normalizeAPIAddress(value: string): string { const candidate = value.trim(); if (!candidate) throw new Error("请输入 AppServer 地址。"); const withProtocol = /^https?:\/\//i.test(candidate) ? candidate : `http://${candidate}`; let parsed: URL; try { parsed = new URL(withProtocol); } catch { throw new Error("AppServer 地址格式不正确,例如 http://100.121.118.116:8090。"); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("AppServer 地址仅支持 http:// 或 https://。"); if (!parsed.hostname) throw new Error("AppServer 地址缺少主机名或 IP 地址。"); return parsed.toString().replace(/\/$/, ""); } function loginFailureMessage(reason: unknown): string { if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,并核对上方显示的服务地址。"; if (reason instanceof DOMException && reason.name === "AbortError") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,且服务地址和端口可访问。"; const message = reason instanceof Error ? reason.message : ""; if (message === "Load failed" || message === "Failed to fetch") return "无法连接 AppServer。请确认 Tailscale 已连接,并检查地址是否为 http://100.121.118.116:8090。"; return message || "登录失败,请稍后重试。"; } async function fetchLogin(endpoint: string, phone: string, code: string): Promise { const controller = new AbortController(); let timeoutID = 0; const timeout = new Promise((_, reject) => { timeoutID = window.setTimeout(() => { controller.abort(); reject(new Error("LINEUP_LOGIN_TIMEOUT")); }, 10_000); }); try { return await Promise.race([ fetch(`${endpoint}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone, code }), signal: controller.signal }), timeout, ]); } finally { window.clearTimeout(timeoutID); } } 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"; if (markdown) { bubble.classList.add("markdown"); 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" })}${delivery ? ` · ${delivery}` : ""}`; bubble.append(meta); row.append(bubble); messages.append(row); scrollLatest(); return row; } function showThinking(detail = "正在思考…"): void { thinking?.remove(); thinking = appendBubble("agent", detail); thinking.classList.add("thinking"); setPresence("正在思考"); } function hideThinking(): void { thinking?.remove(); thinking = undefined; } function render(item: ConversationItem): void { switch (item.kind) { 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; } case "error": hideThinking(); addSystem(`Agent 错误:${item.message}`); break; case "surface": addSystem(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); break; case "app-call": addSystem("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。"); break; case "fallback": addSystem(item.reason === "unsupported_type" ? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}` : "收到无法安全展示的消息。"); break; } } // M0-03 establishes the dispatch boundary. M0-06 will move these individual // 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); rendererRegistry.register("error", render); rendererRegistry.register("surface", render); rendererRegistry.register("app-call", render); rendererRegistry.register("fallback", render); 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; } } async function sendPayload(payload: string): Promise { const response = await fetch(api("/messages/send"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_uid: session.uid, channel_id: session.channelID, channel_type: session.channelType, payload }) }); const body = await response.json().catch(() => ({})) as { error?: string; message_seq?: number }; if (!response.ok) throw new Error(body.error || "消息发送失败"); return body.message_seq; } async function syncLoop(): Promise { if (polling) return; polling = true; while (session.active) { try { // WuKongIM's legacy sync endpoint can return a single retained message // per request even when `more` is 0. Drain until it is empty before // switching to the paced live-polling loop; otherwise a reopened chat // with history needs minutes to reach the latest Agent response. while (session.active) { const startMessageSeq = session.lastSeq === 0 ? 0 : session.lastSeq + 1; const response = await fetch(api("/messages/sync"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel_id: session.channelID, channel_type: session.channelType, login_uid: session.uid, start_message_seq: startMessageSeq, limit: 50 }) }); const body = await response.json().catch(() => ({})) as { error?: string; messages?: SyncedMessage[] }; if (!response.ok) throw new Error(body.error || "同步失败"); const synced = body.messages ?? []; if (synced.length === 0) { setPresence("已同步,等待消息"); break; } let advanced = false; for (const message of synced) { const previousSeq = session.lastSeq; session.lastSeq = Math.max(session.lastSeq, message.message_seq); advanced ||= session.lastSeq > previousSeq; 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: payload, source: "sync", transport_id: String(message.message_seq), }); for (const item of result.items) { if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item); } } } // Avoid a hot loop if an upstream response repeats a sequence that // cannot advance the local cursor. if (!advanced) { setPresence("已同步,等待消息"); break; } } } catch { if (session.active) setPresence("同步中断,正在重试…"); } await new Promise(resolve => window.setTimeout(resolve, 1500)); } polling = false; } $("#login-form").addEventListener("submit", async event => { event.preventDefault(); const apiInput = $("#api"); const phone = $("#phone").value.trim(); const code = $("#code").value.trim(); const error = $("#login-error"); const submit = $("#login-submit"); let endpoint: string; try { endpoint = normalizeAPIAddress(apiInput.value); } catch (reason) { error.textContent = loginFailureMessage(reason); apiInput.focus(); return; } if (!phone) { error.textContent = "请输入手机号。"; $("#phone").focus(); return; } if (!code) { error.textContent = "请输入验证码。"; $("#code").focus(); return; } session.api = endpoint; localStorage.setItem("lineup.api", session.api); error.textContent = `正在连接 ${session.api}…`; submit.disabled = true; submit.textContent = "正在进入…"; try { const response = await fetchLogin(session.api, phone, code); const body = await response.json().catch(() => ({})) as LoginResponse & { error?: string }; 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 }; 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"; } }); $("#message-form").addEventListener("submit", async event => { event.preventDefault(); const input = $("#message-input"); const send = $("#send"); const text = input.value.trim(); if (!text) return; if (!session.uid) { addSystem("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); input.focus(); return; } input.value = ""; send.disabled = true; const localID = newLocalID("local"); const createdAt = new Date().toISOString(); // A browser storage failure must never prevent the user from seeing or // submitting their message. The Store remains the durable source when it // is available; the server sync is the recovery path when it is not. const row = appendBubble("human", text, false, "发送中"); const meta = row.querySelector(".meta"); let storedLocally = true; try { conversationStore.appendLocalText(localID, text, createdAt); } catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } try { const sequence = await sendPayload(text); if (sequence && storedLocally) { try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); } catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } } if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送`; } catch (reason) { const message = reason instanceof Error ? reason.message : "发送失败"; if (storedLocally) { try { conversationStore.markFailed(localID, message, new Date().toISOString()); } catch (storageReason) { addSystem(storageFailureMessage(storageReason)); } } if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败`; addSystem(message); } finally { send.disabled = false; input.focus(); } }); $("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); export function makeProtocolEvent( type: string, payload: JsonObject, conversationID: string, sender: Actor, target?: Actor, ): Envelope { return { v: 1, id: newLocalID("msg"), type, conversation_id: conversationID, sender, ...(target ? { target } : {}), timestamp: new Date().toISOString(), payload, }; }