Files
app/tauri/src/main.ts
T

352 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}