feat: reliably deliver tool interaction responses
This commit is contained in:
+75
-3
@@ -4,6 +4,7 @@ import {
|
||||
type ConversationItem,
|
||||
type Envelope,
|
||||
type JsonObject,
|
||||
parseEnvelopeJSON,
|
||||
} from "./protocol";
|
||||
import { ConversationStore } from "./runtime/conversation-store";
|
||||
import { InteractionKernel } from "./runtime/interaction-kernel";
|
||||
@@ -25,6 +26,7 @@ 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 flushingOutbox = false;
|
||||
const interactionKernel = new InteractionKernel();
|
||||
const conversationStore = new ConversationStore(localStorage);
|
||||
|
||||
@@ -98,13 +100,19 @@ const rendererContext = new TrustedDOMRendererContext(messages, presence, {
|
||||
submit(callID, submission) {
|
||||
const transition = interactionKernel.submitToolCall(callID, new Date().toISOString(), submission);
|
||||
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
|
||||
if (transition.disposition === "accepted") conversationStore.clearToolDraft(callID);
|
||||
if (transition.disposition === "accepted") {
|
||||
conversationStore.clearToolDraft(callID);
|
||||
queueToolAction("tool-result", callID, { call_id: callID, status: "completed", result: submission });
|
||||
}
|
||||
return transition.disposition === "accepted";
|
||||
},
|
||||
cancel(callID, reason) {
|
||||
const transition = interactionKernel.cancelToolCall(callID, reason, new Date().toISOString());
|
||||
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
|
||||
if (transition.disposition === "accepted") conversationStore.clearToolDraft(callID);
|
||||
if (transition.disposition === "accepted") {
|
||||
conversationStore.clearToolDraft(callID);
|
||||
queueToolAction("tool-cancel", callID, { call_id: callID, reason });
|
||||
}
|
||||
return transition.disposition === "accepted";
|
||||
},
|
||||
expire(callID) {
|
||||
@@ -145,6 +153,64 @@ function currentConversationKey(): string {
|
||||
return `${session.uid}:${session.channelType}:${session.channelID}`;
|
||||
}
|
||||
|
||||
function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, payload: JsonObject): void {
|
||||
if (!session.uid) {
|
||||
rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。");
|
||||
return;
|
||||
}
|
||||
const createdAt = new Date().toISOString();
|
||||
const localID = newLocalID("tool");
|
||||
const type = kind === "tool-result" ? "lineup.v1.tool.result" : "lineup.v1.tool.cancel";
|
||||
const envelope = makeProtocolEvent(
|
||||
type, payload, currentConversationKey(),
|
||||
{ kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID },
|
||||
);
|
||||
try {
|
||||
// Durable enqueue precedes the HTTP request: a reload or transient
|
||||
// network failure cannot turn one accepted UI action into an untracked
|
||||
// second submission.
|
||||
conversationStore.enqueueToolAction(localID, kind, JSON.stringify(envelope), createdAt);
|
||||
} catch (reason) {
|
||||
rendererContext.renderSystemMessage(storageFailureMessage(reason));
|
||||
return;
|
||||
}
|
||||
void flushOutbox();
|
||||
}
|
||||
|
||||
async function flushOutbox(): Promise<void> {
|
||||
if (flushingOutbox || !session.active || !transport || !session.uid) return;
|
||||
flushingOutbox = true;
|
||||
try {
|
||||
for (const entry of conversationStore.snapshot().outbox) {
|
||||
try {
|
||||
const sequence = await transport.sendText({
|
||||
from_uid: session.uid,
|
||||
channel_id: session.channelID,
|
||||
channel_type: session.channelType,
|
||||
payload: entry.payload,
|
||||
});
|
||||
if (entry.kind === "text") {
|
||||
if (sequence) conversationStore.markSubmitted(entry.local_id, sequence, new Date().toISOString());
|
||||
rendererContext.updateUserDelivery(entry.local_id, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) });
|
||||
} else {
|
||||
conversationStore.acknowledgeOutbox(entry.local_id);
|
||||
}
|
||||
} catch (reason) {
|
||||
if (entry.kind === "text") {
|
||||
const message = reason instanceof Error ? reason.message : "发送失败";
|
||||
conversationStore.markFailed(entry.local_id, message, new Date().toISOString());
|
||||
rendererContext.updateUserDelivery(entry.local_id, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true });
|
||||
}
|
||||
// Keep this and later entries durable; the next sync/login cycle
|
||||
// retries in original order.
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushingOutbox = false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64(value: string): string {
|
||||
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; }
|
||||
}
|
||||
@@ -182,6 +248,12 @@ async function syncLoop(): Promise<void> {
|
||||
conversationStore.advanceCursor(message.message_seq);
|
||||
const payload = decodeBase64(message.payload);
|
||||
if (message.from_uid === session.uid) {
|
||||
const ownEnvelope = parseEnvelopeJSON(payload);
|
||||
if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel")) {
|
||||
// Tool action echoes advance the inclusive cursor but are never
|
||||
// rendered as raw JSON user messages or replayed as a new action.
|
||||
continue;
|
||||
}
|
||||
const restored = conversationStore.recordSyncedUserText(message.message_seq, payload, new Date().toISOString());
|
||||
if (restored) renderIncoming(restored.item);
|
||||
} else {
|
||||
@@ -250,7 +322,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
messages.replaceChildren(); rendererContext.reset();
|
||||
loginView.hidden = true; chatView.hidden = false;
|
||||
for (const stored of snapshot.items) renderIncoming(stored.item);
|
||||
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void syncLoop();
|
||||
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop();
|
||||
} catch (reason) { error.textContent = loginFailureMessage(reason); }
|
||||
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user