Files
ttstd-infinite-canvas/web/src/services/cloud-agent-conversations.ts
T

144 lines
7.0 KiB
TypeScript

import { localForageStorageForScope } from "@/lib/localforage-storage";
import { markdownPlainText } from "@/lib/markdown-plain-text";
import { getActiveUserScope } from "@/lib/user-scope";
import type { AgentPermissionMode, AgentRun } from "@/services/api/agent";
export type CloudAgentConversationMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool" | "error";
title?: string;
text: string;
streaming?: boolean;
reasoning?: boolean;
planItems?: Array<{ id: string; title: string; status: "pending" | "doing" | "done" }>;
planTerminal?: boolean;
question?: { question: string; options: Array<{ label: string; detail?: string }>; allowFreeform?: boolean };
meta?: string;
detail?: unknown;
attachments?: Array<{ id: string; name: string; url: string }>;
interjection?: "sent" | "undelivered";
};
export type CloudAgentConversation = {
id: string;
title: string;
messages: CloudAgentConversationMessage[];
run: AgentRun | null;
model?: string;
permissionMode: AgentPermissionMode;
skillIds?: string[];
createdAt: string;
updatedAt: string;
};
type CloudAgentConversationDocument = {
version: 1;
activeId: string | null;
conversations: CloudAgentConversation[];
};
export type CloudAgentPendingSubmission = {
fingerprint: string;
key: string;
request?: import("@/services/api/agent").CreateAgentRunInput;
parentRunId?: string;
messageId?: string;
};
const CLOUD_AGENT_CONVERSATIONS_KEY = "cloud-agent-conversations-v1";
export async function loadCloudAgentConversations(canvasId: string): Promise<CloudAgentConversationDocument> {
if (!canvasId) throw new Error("缺少画布 ID,无法读取 Agent 对话");
const value = await localForageStorageForScope(getActiveUserScope()).getItem(storageKey(canvasId));
if (!value) return emptyDocument();
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("Agent 对话历史已损坏");
}
if (!isConversationDocument(parsed)) throw new Error("Agent 对话历史格式无效");
return parsed;
}
export async function saveCloudAgentConversations(canvasId: string, activeId: string | null, conversations: CloudAgentConversation[]) {
if (!canvasId) throw new Error("缺少画布 ID,无法保存 Agent 对话");
// 流式标记只属于内存中的观察状态。若网络在最后一个 delta 后断开,
// 也不能让下次恢复把思考卡片误判为仍在实时展开。
const persistedConversations = conversations.map((conversation) => ({
...conversation,
messages: conversation.messages.map((message) => ({ ...message, streaming: false })),
}));
const document: CloudAgentConversationDocument = { version: 1, activeId, conversations: persistedConversations };
await localForageStorageForScope(getActiveUserScope()).setItem(storageKey(canvasId), JSON.stringify(document));
}
// Keep the retry key outside the chat snapshot. If the response is lost, a
// page refresh can safely repeat the same request without creating another
// billed Agent turn.
export async function loadCloudAgentPendingSubmission(canvasId: string, conversationId: string) {
if (!canvasId || !conversationId) return null;
const value = await localForageStorageForScope(getActiveUserScope()).getItem(pendingStorageKey(canvasId, conversationId));
if (value === null || value === undefined) return null;
// A corrupt recovery record is not proof that the previous POST never ran.
// Fail closed rather than discarding its identity and risking another charge.
try {
const parsed = JSON.parse(value) as Partial<CloudAgentPendingSubmission> | null;
if (!parsed || typeof parsed.fingerprint !== "string" || typeof parsed.key !== "string" || parsed.key.length < 8) throw new Error("invalid identity");
if (parsed.request !== undefined && (!parsed.request || parsed.request.idempotencyKey !== parsed.key || parsed.request.canvasId !== canvasId || typeof parsed.request.prompt !== "string")) throw new Error("invalid request");
if (parsed.parentRunId !== undefined && typeof parsed.parentRunId !== "string") throw new Error("invalid parent");
if (parsed.messageId !== undefined && typeof parsed.messageId !== "string") throw new Error("invalid message");
return { fingerprint: parsed.fingerprint, key: parsed.key, request: parsed.request, parentRunId: parsed.parentRunId, messageId: parsed.messageId } satisfies CloudAgentPendingSubmission;
} catch {
throw new Error("待确认请求记录损坏,已暂停本对话发送;请先在任务中心核对原运行,不要直接重复生成");
}
}
export async function saveCloudAgentPendingSubmission(canvasId: string, conversationId: string, pending: CloudAgentPendingSubmission) {
if (!canvasId || !conversationId) throw new Error("缺少 Agent 对话范围,无法保存幂等提交");
await localForageStorageForScope(getActiveUserScope()).setItem(pendingStorageKey(canvasId, conversationId), JSON.stringify(pending));
}
export async function clearCloudAgentPendingSubmission(canvasId: string, conversationId: string) {
if (!canvasId || !conversationId) return;
await localForageStorageForScope(getActiveUserScope()).removeItem(pendingStorageKey(canvasId, conversationId));
}
export function cloudAgentConversationTitle(messages: CloudAgentConversationMessage[]) {
const firstPrompt = displayConversationTitle(messages.find((message) => message.role === "user")?.text || "");
return firstPrompt.length > 28 ? `${firstPrompt.slice(0, 28)}...` : firstPrompt;
}
function displayConversationTitle(value: string) {
return markdownPlainText(value.replace(/@\[skill:[^\]]+\]/gu, "技能包")) || "新对话";
}
function storageKey(canvasId: string) {
return `${CLOUD_AGENT_CONVERSATIONS_KEY}:${encodeURIComponent(canvasId)}`;
}
function pendingStorageKey(canvasId: string, conversationId: string) {
return `${CLOUD_AGENT_CONVERSATIONS_KEY}:pending:${encodeURIComponent(canvasId)}:${encodeURIComponent(conversationId)}`;
}
function emptyDocument(): CloudAgentConversationDocument {
return { version: 1, activeId: null, conversations: [] };
}
function isConversationDocument(value: unknown): value is CloudAgentConversationDocument {
if (!value || typeof value !== "object") return false;
const document = value as Partial<CloudAgentConversationDocument>;
if (document.version !== 1 || !Array.isArray(document.conversations)) return false;
return document.conversations.every((conversation) => {
if (!conversation || typeof conversation !== "object") return false;
const candidate = conversation as Partial<CloudAgentConversation>;
return typeof candidate.id === "string"
&& typeof candidate.title === "string"
&& Array.isArray(candidate.messages)
&& typeof candidate.createdAt === "string"
&& typeof candidate.updatedAt === "string"
&& ["read_only", "auto", "request_approval"].includes(candidate.permissionMode || "");
});
}