352 lines
18 KiB
TypeScript
352 lines
18 KiB
TypeScript
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<HTMLDivElement>("#app");
|
||
if (!app) throw new Error("LineUp host root is missing");
|
||
|
||
app.innerHTML = `
|
||
<main class="shell">
|
||
<section id="login-view" class="login-view">
|
||
<div class="brand"><span>✦</span><div><p>LINEUP</p><h1>你的 AI 协作空间</h1></div></div>
|
||
<form id="login-form" class="login-card" novalidate>
|
||
<label>AppServer 地址<input id="api" inputmode="url" placeholder="http://100.x.y.z:8090" required /></label>
|
||
<label>手机号<input id="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号" required /></label>
|
||
<label>验证码<input id="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入验证码" required /></label>
|
||
<p id="login-error" class="error" role="alert"></p>
|
||
<button id="login-submit" type="submit">进入 LineUp</button>
|
||
</form>
|
||
</section>
|
||
<section id="chat-view" class="chat-view" hidden>
|
||
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><button id="logout" class="quiet">退出</button></header>
|
||
<section id="messages" class="messages" aria-live="polite"></section>
|
||
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
|
||
</section>
|
||
</main>`;
|
||
|
||
const $ = <T extends Element>(selector: string): T => {
|
||
const found = document.querySelector<T>(selector);
|
||
if (!found) throw new Error(`Missing element: ${selector}`);
|
||
return found;
|
||
};
|
||
const messages = $<HTMLElement>("#messages");
|
||
const loginView = $<HTMLElement>("#login-view");
|
||
const chatView = $<HTMLElement>("#chat-view");
|
||
const presence = $<HTMLElement>("#presence");
|
||
$<HTMLInputElement>("#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<Response> {
|
||
const controller = new AbortController();
|
||
let timeoutID = 0;
|
||
const timeout = new Promise<never>((_, 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<void>();
|
||
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<number | undefined> {
|
||
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<void> {
|
||
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;
|
||
}
|
||
|
||
$<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const apiInput = $<HTMLInputElement>("#api");
|
||
const phone = $<HTMLInputElement>("#phone").value.trim();
|
||
const code = $<HTMLInputElement>("#code").value.trim();
|
||
const error = $<HTMLElement>("#login-error");
|
||
const submit = $<HTMLButtonElement>("#login-submit");
|
||
let endpoint: string;
|
||
try { endpoint = normalizeAPIAddress(apiInput.value); }
|
||
catch (reason) { error.textContent = loginFailureMessage(reason); apiInput.focus(); return; }
|
||
if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; }
|
||
if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#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"; }
|
||
});
|
||
|
||
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#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<HTMLElement>(".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(); }
|
||
});
|
||
|
||
$<HTMLButtonElement>("#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,
|
||
};
|
||
}
|