feat: isolate appserver transport adapter
This commit is contained in:
+22
-46
@@ -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 = $<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(); }
|
||||
@@ -108,25 +100,6 @@ function loginFailureMessage(reason: unknown): string {
|
||||
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); }
|
||||
@@ -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<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;
|
||||
@@ -208,11 +174,15 @@ async function syncLoop(): Promise<void> {
|
||||
// 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 @@ $<HTMLFormElement>("#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 @@ $<HTMLFormElement>("#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 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
||||
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; });
|
||||
$<HTMLButtonElement>("#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,
|
||||
|
||||
@@ -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<LoginResult>;
|
||||
sendText(request: SendTextRequest): Promise<number | undefined>;
|
||||
sync(request: SyncRequest): Promise<readonly TransportMessage[]>;
|
||||
}
|
||||
|
||||
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<LoginResult> {
|
||||
const response = await this.fetchLogin(request);
|
||||
const body = await response.json().catch(() => ({})) as ErrorResponse & Partial<LoginResult>;
|
||||
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<number | undefined> {
|
||||
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<readonly TransportMessage[]> {
|
||||
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<TransportMessage>;
|
||||
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<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(this.baseURL, "/login"), {
|
||||
method: "POST", headers: JSON_HEADERS, body: JSON.stringify(request), signal: controller.signal,
|
||||
}),
|
||||
timeout,
|
||||
]);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutID);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user