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; meta?: string; detail?: unknown; attachments?: Array<{ id: string; name: string; url: string }>; }; 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 { 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 对话"); const document: CloudAgentConversationDocument = { version: 1, activeId, conversations }; 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 | 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; 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; 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 || ""); }); }