diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 8c945d7..6320966 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -10,6 +10,7 @@ import { import { ConversationStore } from "./runtime/conversation-store"; import { InteractionKernel } from "./runtime/interaction-kernel"; import { RendererRegistry } from "./runtime/renderer-registry"; +import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter"; type Session = { api: string; @@ -21,17 +22,9 @@ type Session = { 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 transport: TransportAdapter | undefined; let polling = false; let thinking: HTMLElement | undefined; const interactionKernel = new InteractionKernel(); @@ -70,7 +63,6 @@ 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(); } @@ -108,25 +100,6 @@ function loginFailureMessage(reason: unknown): string { 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); } @@ -190,13 +163,6 @@ 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; @@ -208,11 +174,15 @@ async function syncLoop(): Promise { // 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 ?? []; + const activeTransport = transport; + if (!activeTransport) throw new Error("当前会话传输尚未初始化。"); + const synced = await activeTransport.sync({ + channel_id: session.channelID, + channel_type: session.channelType, + login_uid: session.uid, + start_message_seq: startMessageSeq, + limit: 50, + }); if (synced.length === 0) { setPresence("已同步,等待消息"); break; @@ -274,10 +244,10 @@ $("#login-form").addEventListener("submit", async event => { 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 || "登录失败"); + const candidateTransport = new HttpTransportAdapter(session.api); + const body = await candidateTransport.login({ phone, code }); interactionKernel.reset(); + transport = candidateTransport; 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; @@ -310,7 +280,13 @@ $("#message-form").addEventListener("submit", async event => { try { conversationStore.appendLocalText(localID, text, createdAt); } catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } try { - const sequence = await sendPayload(text); + if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。"); + const sequence = await transport.sendText({ + from_uid: session.uid, + channel_id: session.channelID, + channel_type: session.channelType, + payload: text, + }); if (sequence && storedLocally) { try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); } catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } @@ -329,7 +305,7 @@ $("#message-form").addEventListener("submit", async event => { finally { send.disabled = false; input.focus(); } }); -$("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); +$("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); export function makeProtocolEvent( type: string, diff --git a/tauri/src/runtime/transport-adapter.ts b/tauri/src/runtime/transport-adapter.ts new file mode 100644 index 0000000..1411b1d --- /dev/null +++ b/tauri/src/runtime/transport-adapter.ts @@ -0,0 +1,124 @@ +/** + * The Transport Adapter is the only M0 boundary that knows the current + * AppServer HTTP endpoints. Its callers work with conversation data and do + * not receive Response objects or call fetch directly, so this contract can + * later be implemented by a WebSocket transport without changing the Shell + * or Interaction Kernel. + */ + +export type LoginRequest = { + phone: string; + code: string; +}; + +export type LoginResult = { + uid: string; + agent_uid?: string; + channel_id?: string; + channel_type?: number; +}; + +export type SendTextRequest = { + from_uid: string; + channel_id: string; + channel_type: number; + payload: string; +}; + +export type SyncRequest = { + channel_id: string; + channel_type: number; + login_uid: string; + start_message_seq: number; + limit: number; +}; + +export type TransportMessage = { + message_seq: number; + from_uid: string; + payload: string; +}; + +export interface TransportAdapter { + login(request: LoginRequest): Promise; + sendText(request: SendTextRequest): Promise; + sync(request: SyncRequest): Promise; +} + +type ErrorResponse = { error?: unknown }; +type SendResponse = ErrorResponse & { message_seq?: unknown }; +type SyncResponse = ErrorResponse & { messages?: unknown }; + +const JSON_HEADERS = { "Content-Type": "application/json" }; + +function errorMessage(body: ErrorResponse, fallback: string): string { + return typeof body.error === "string" && body.error ? body.error : fallback; +} + +function endpoint(baseURL: string, path: string): string { + return `${baseURL.replace(/\/$/, "")}${path}`; +} + +/** Browser HTTP implementation for the current AppServer development host. */ +export class HttpTransportAdapter implements TransportAdapter { + public constructor(private readonly baseURL: string) {} + + public async login(request: LoginRequest): Promise { + const response = await this.fetchLogin(request); + const body = await response.json().catch(() => ({})) as ErrorResponse & Partial; + if (!response.ok) throw new Error(errorMessage(body, "登录失败")); + if (typeof body.uid !== "string" || !body.uid) throw new Error("登录响应缺少用户标识。"); + return { + uid: body.uid, + ...(typeof body.agent_uid === "string" ? { agent_uid: body.agent_uid } : {}), + ...(typeof body.channel_id === "string" ? { channel_id: body.channel_id } : {}), + ...(typeof body.channel_type === "number" ? { channel_type: body.channel_type } : {}), + }; + } + + public async sendText(request: SendTextRequest): Promise { + const response = await fetch(endpoint(this.baseURL, "/messages/send"), { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(request), + }); + const body = await response.json().catch(() => ({})) as SendResponse; + if (!response.ok) throw new Error(errorMessage(body, "消息发送失败")); + return typeof body.message_seq === "number" ? body.message_seq : undefined; + } + + public async sync(request: SyncRequest): Promise { + const response = await fetch(endpoint(this.baseURL, "/messages/sync"), { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(request), + }); + const body = await response.json().catch(() => ({})) as SyncResponse; + if (!response.ok) throw new Error(errorMessage(body, "同步失败")); + if (!Array.isArray(body.messages)) return []; + return body.messages.flatMap(value => { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const message = value as Partial; + return typeof message.message_seq === "number" && typeof message.from_uid === "string" && typeof message.payload === "string" + ? [{ message_seq: message.message_seq, from_uid: message.from_uid, payload: message.payload }] + : []; + }); + } + + private async fetchLogin(request: LoginRequest): 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(this.baseURL, "/login"), { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(request), signal: controller.signal, + }), + timeout, + ]); + } finally { + window.clearTimeout(timeoutID); + } + } +}