diff --git a/apps/console/src/api/__tests__/agent.spec.ts b/apps/console/src/api/__tests__/agent.spec.ts new file mode 100644 index 0000000000..5ab8daf873 --- /dev/null +++ b/apps/console/src/api/__tests__/agent.spec.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { apiGet, apiPost, apiPut } from "@/utils/request"; +import { agentApi } from "@/api/agent"; + +vi.mock("@/utils/request", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), +})); + +describe("agentApi", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses the authenticated Agent workflow endpoints and explicit confirmation payload", async () => { + const thread = { threadId: "thread-1" }; + vi.mocked(apiPost).mockResolvedValue(thread); + vi.mocked(apiPut).mockResolvedValue(thread); + vi.mocked(apiGet).mockResolvedValue(thread); + + await agentApi.createThread("submission-1", "review this"); + expect(apiPost).toHaveBeenCalledWith("/agent/threads", { + sourceSubmissionId: "submission-1", + question: "review this", + }); + await agentApi.confirm("thread-1", { + draftVersion: 2, + title: "Title", + content: "Content", + facts: [], + hypotheses: [], + citations: [], + citationChecks: [], + sampleScope: null, + }, "digest"); + expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/confirm", { + draftVersion: 2, + paramsDigest: "digest", + confirm: true, + }); + await agentApi.save("thread-1", "confirmation-1"); + expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/save", { + confirmationId: "confirmation-1", + }); + await agentApi.recover("thread-1"); + expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/recover", { retry: false }); + await agentApi.cancel("thread-1"); + expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/cancel", {}); + await agentApi.updateDraft("thread-1", { draftVersion: 2, title: "Title", content: "Content" }); + expect(apiPut).toHaveBeenCalledWith("/agent/threads/thread-1/draft", { + draftVersion: 2, + title: "Title", + content: "Content", + }); + }); + + it("encodes thread IDs for reads and event streams", async () => { + vi.mocked(apiGet).mockResolvedValue({}); + await agentApi.getThread("thread/with space"); + expect(apiGet).toHaveBeenCalledWith("/agent/threads/thread%2Fwith%20space"); + expect(agentApi.eventStreamUrl("thread/with space")).toBe( + "/api/agent/threads/thread%2Fwith%20space/events/stream?after=0", + ); + }); +}); diff --git a/apps/console/src/api/agent.ts b/apps/console/src/api/agent.ts new file mode 100644 index 0000000000..e7764e6f03 --- /dev/null +++ b/apps/console/src/api/agent.ts @@ -0,0 +1,109 @@ +import { apiGet, apiPost, apiPut } from "@/utils/request"; + +export type AgentThreadStatus = + | "awaiting_confirmation" + | "analyzing" + | "confirmed" + | "saving" + | "saved" + | "unknown" + | "failed" + | "cancelled"; + +export interface AgentCitation { + claim: string; + chunk_id: string; + doc_id: string; + version: string; + source_path: string; + source_position: string; + access_scope: string; + sample_kind: string; + source_trust: string; + text: string; +} + +export interface AgentWorkflowEvent { + seq: number; + kind: string; + detail: Record; + createdAt: number; +} + +export interface AgentThread { + threadId: string; + runId: string; + status: AgentThreadStatus; + sourceSubmissionId: string; + question: string; + draft: { + draftVersion: number; + title: string; + content: string; + facts: unknown[]; + hypotheses: unknown[]; + citations: AgentCitation[]; + citationChecks: Array>; + sampleScope: string | null; + model_answer?: string; + }; + paramsDigest: string; + analysis: { + answer?: string; + trace?: Array<{ tool_name?: string; failed?: boolean }>; + citation_checks?: Array>; + } | null; + confirmation: { + id?: string; + action?: string; + draftVersion?: number; + paramsDigest?: string; + expiresAt?: number; + } | null; + receipt: { + planId: string | null; + cancelRequested: boolean; + failureReason: string | null; + }; +} + +const threadPath = (threadId: string) => + `/agent/threads/${encodeURIComponent(threadId)}`; + +export const agentApi = { + createThread(sourceSubmissionId: string, question: string) { + return apiPost("/agent/threads", { sourceSubmissionId, question }); + }, + getThread(threadId: string) { + return apiGet(threadPath(threadId)); + }, + analyze(threadId: string) { + return apiPost(`${threadPath(threadId)}/analyze`, {}); + }, + updateDraft( + threadId: string, + draft: { draftVersion: number; title: string; content: string }, + ) { + return apiPut(`${threadPath(threadId)}/draft`, draft); + }, + confirm(threadId: string, draft: AgentThread["draft"], paramsDigest: string) { + return apiPost(`${threadPath(threadId)}/confirm`, { + draftVersion: draft.draftVersion, + paramsDigest, + confirm: true, + }); + }, + save(threadId: string, confirmationId: string) { + return apiPost(`${threadPath(threadId)}/save`, { confirmationId }); + }, + recover(threadId: string) { + return apiPost(`${threadPath(threadId)}/recover`, { retry: false }); + }, + cancel(threadId: string) { + return apiPost(`${threadPath(threadId)}/cancel`, {}); + }, + eventStreamUrl(threadId: string) { + const base = (import.meta.env.VITE_API_BASE_URL || "/api").replace(/\/$/, ""); + return `${base}${threadPath(threadId)}/events/stream?after=0`; + }, +}; diff --git a/apps/console/src/composables/__tests__/useAgentWorkflow.spec.ts b/apps/console/src/composables/__tests__/useAgentWorkflow.spec.ts new file mode 100644 index 0000000000..80c21cc4c6 --- /dev/null +++ b/apps/console/src/composables/__tests__/useAgentWorkflow.spec.ts @@ -0,0 +1,265 @@ +import { effectScope } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { agentApi, type AgentThread } from "@/api/agent"; +import { useAgentWorkflow } from "@/composables/useAgentWorkflow"; + +vi.mock("@/api/agent", () => ({ + agentApi: { + getThread: vi.fn(), + createThread: vi.fn(), + analyze: vi.fn(), + updateDraft: vi.fn(), + confirm: vi.fn(), + save: vi.fn(), + recover: vi.fn(), + cancel: vi.fn(), + eventStreamUrl: vi.fn((id: string) => `/api/agent/threads/${id}/events/stream?after=0`), + }, +})); + +class FakeEventSource { + static instances: FakeEventSource[] = []; + private listeners = new Map) => void>(); + onerror: ((event: Event) => unknown) | null = null; + closed = false; + + constructor(public url: string) { + FakeEventSource.instances.push(this); + } + + addEventListener(type: string, listener: EventListener) { + this.listeners.set(type, listener as (event: MessageEvent) => void); + } + + close() { + this.closed = true; + } + + emit(type: string, payload: Record) { + this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(payload) })); + } + + fail() { + this.onerror?.(new Event("error")); + } +} + +function thread(threadId: string, runId: string, status: AgentThread["status"]): AgentThread { + return { + threadId, + runId, + status, + sourceSubmissionId: "submission-1", + question: "review this", + draft: { + draftVersion: 1, + title: "Draft", + content: "Content", + facts: [], + hypotheses: [], + citations: [], + citationChecks: [], + sampleScope: null, + }, + paramsDigest: "digest", + analysis: null, + confirmation: null, + receipt: { planId: null, cancelRequested: false, failureReason: null }, + }; +} + +describe("useAgentWorkflow run isolation", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + FakeEventSource.instances = []; + }); + + it("ignores late events from a cancelled thread after a new thread starts", async () => { + vi.stubGlobal("EventSource", FakeEventSource); + const first = thread("thread-a", "run-a", "analyzing"); + const cancelled = { ...first, status: "cancelled" as const }; + const second = thread("thread-b", "run-b", "awaiting_confirmation"); + vi.mocked(agentApi.getThread).mockResolvedValueOnce(first); + vi.mocked(agentApi.cancel).mockResolvedValue(cancelled); + vi.mocked(agentApi.createThread).mockResolvedValue(second); + + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + await workflow.loadThread(first.threadId); + const oldStream = FakeEventSource.instances[0]; + expect(oldStream.url).toContain("thread-a"); + + await workflow.cancel(); + expect(oldStream.closed).toBe(true); + await workflow.createThread("submission-2", "new question"); + oldStream.emit("text", { runId: first.runId, text: "stale answer" }); + + expect(workflow.thread.value?.threadId).toBe(second.threadId); + expect(workflow.generatedText.value).toBe(""); + scope.stop(); + }); + + it("reloads and subscribes to the canonical run when the server replaces a run", async () => { + vi.stubGlobal("EventSource", FakeEventSource); + const previous = thread("thread-a", "run-a", "analyzing"); + const current = thread("thread-a", "run-b", "analyzing"); + vi.mocked(agentApi.getThread).mockResolvedValueOnce(previous).mockResolvedValueOnce(current); + + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + await workflow.loadThread(previous.threadId); + FakeEventSource.instances[0].emit("stream_closed", { + runId: previous.runId, + reason: "run_replaced", + }); + + await vi.waitFor(() => expect(workflow.thread.value?.runId).toBe(current.runId)); + expect(FakeEventSource.instances).toHaveLength(2); + expect(FakeEventSource.instances[1].closed).toBe(false); + scope.stop(); + }); + + it("submits only one confirmation and keeps confirmation separate from saving", async () => { + const awaiting = thread("thread-a", "run-a", "awaiting_confirmation"); + const confirmed = { + ...awaiting, + status: "confirmed" as const, + confirmation: { id: "confirmation-1" }, + }; + let resolveConfirm!: (value: AgentThread) => void; + const confirmRequest = new Promise((resolve) => { + resolveConfirm = resolve; + }); + vi.mocked(agentApi.getThread).mockResolvedValue(awaiting); + vi.mocked(agentApi.confirm).mockReturnValue(confirmRequest); + + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + await workflow.loadThread(awaiting.threadId); + + const firstConfirm = workflow.confirmDraft(); + const duplicateConfirm = workflow.confirmDraft(); + expect(agentApi.confirm).toHaveBeenCalledTimes(1); + expect(workflow.busy.value).toBe(true); + + resolveConfirm(confirmed); + await Promise.all([firstConfirm, duplicateConfirm]); + expect(workflow.thread.value?.status).toBe("confirmed"); + expect(agentApi.save).not.toHaveBeenCalled(); + scope.stop(); + }); + + it("shows the canonical confirmation after a lost response and never saves automatically", async () => { + const awaiting = thread("thread-a", "run-a", "awaiting_confirmation"); + const confirmed = { + ...awaiting, + status: "confirmed" as const, + confirmation: { id: "confirmation-1" }, + }; + vi.mocked(agentApi.getThread).mockResolvedValueOnce(awaiting).mockResolvedValueOnce(confirmed); + vi.mocked(agentApi.confirm).mockRejectedValue(new Error("response lost")); + + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + await workflow.loadThread(awaiting.threadId); + await workflow.confirmDraft(); + + expect(workflow.error.value).toBe("requestFailed"); + expect(workflow.thread.value?.status).toBe("confirmed"); + expect(agentApi.save).not.toHaveBeenCalled(); + scope.stop(); + }); + + it("shows the canonical cancellation after a lost response", async () => { + vi.stubGlobal("EventSource", FakeEventSource); + const analyzing = thread("thread-a", "run-a", "analyzing"); + const cancelled = thread("thread-a", "run-a", "cancelled"); + vi.mocked(agentApi.getThread).mockResolvedValueOnce(analyzing).mockResolvedValueOnce(cancelled); + vi.mocked(agentApi.cancel).mockRejectedValue(new Error("response lost")); + + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + await workflow.loadThread(analyzing.threadId); + await workflow.cancel(); + + expect(workflow.error.value).toBe("requestFailed"); + expect(workflow.thread.value?.status).toBe("cancelled"); + expect(FakeEventSource.instances[0].closed).toBe(true); + scope.stop(); + }); + + it.each([ + { reason: "disconnect", expectedError: "streamFailed" }, + { reason: "stream_timeout", expectedError: "streamTimedOut" }, + ])("shows the stream $reason and restores canonical status", async ({ reason, expectedError }) => { + vi.stubGlobal("EventSource", FakeEventSource); + const analyzing = thread("thread-a", "run-a", "analyzing"); + const failed = thread("thread-a", "run-a", "failed"); + vi.mocked(agentApi.getThread).mockResolvedValueOnce(analyzing).mockResolvedValueOnce(failed); + + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + await workflow.loadThread(analyzing.threadId); + const stream = FakeEventSource.instances[0]; + if (reason === "disconnect") stream.fail(); + else stream.emit("stream_closed", { runId: analyzing.runId, reason }); + + await vi.waitFor(() => { + expect(workflow.error.value).toBe(expectedError); + expect(workflow.thread.value?.status).toBe("failed"); + }); + expect(stream.closed).toBe(true); + scope.stop(); + }); + + it.each(["saved", "failed", "cancelled"] as const)( + "restores the canonical %s status when a thread is reopened", + async (status) => { + const canonical = thread("thread-a", "run-a", status); + vi.mocked(agentApi.getThread).mockResolvedValue(canonical); + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + + await workflow.loadThread(canonical.threadId); + + expect(workflow.thread.value?.status).toBe(status); + scope.stop(); + }, + ); + + it("shows saving state and refuses cancellation while a save is in flight", async () => { + const confirmed = { + ...thread("thread-a", "run-a", "confirmed"), + confirmation: { id: "confirmation-1" }, + }; + const saved = { ...confirmed, status: "saved" as const }; + let resolveSave!: (value: AgentThread) => void; + const saveRequest = new Promise((resolve) => { + resolveSave = resolve; + }); + vi.mocked(agentApi.getThread).mockResolvedValue(confirmed); + vi.mocked(agentApi.save).mockReturnValue(saveRequest); + + const scope = effectScope(); + const workflow = scope.run(() => useAgentWorkflow())!; + await workflow.loadThread(confirmed.threadId); + + const pendingSave = workflow.save(); + expect(workflow.saving.value).toBe(true); + await workflow.save(); + expect(agentApi.save).toHaveBeenCalledTimes(1); + await workflow.cancel(); + expect(agentApi.cancel).not.toHaveBeenCalled(); + expect(await workflow.loadThread("thread-b")).toBeNull(); + expect(agentApi.getThread).toHaveBeenCalledTimes(1); + expect(workflow.clear()).toBe(false); + expect(workflow.thread.value?.threadId).toBe(confirmed.threadId); + + resolveSave(saved); + await pendingSave; + expect(workflow.saving.value).toBe(false); + expect(workflow.thread.value?.status).toBe("saved"); + scope.stop(); + }); +}); diff --git a/apps/console/src/composables/useAgentWorkflow.ts b/apps/console/src/composables/useAgentWorkflow.ts new file mode 100644 index 0000000000..d7e54685ce --- /dev/null +++ b/apps/console/src/composables/useAgentWorkflow.ts @@ -0,0 +1,331 @@ +import { onScopeDispose, ref } from "vue"; +import { agentApi, type AgentThread, type AgentWorkflowEvent } from "@/api/agent"; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function useAgentWorkflow() { + const thread = ref(null); + const events = ref([]); + const generatedText = ref(""); + const draftTitle = ref(""); + const draftContent = ref(""); + const error = ref(""); + const busy = ref(false); + const loading = ref(false); + const analyzing = ref(false); + const canceling = ref(false); + const saving = ref(false); + let runGeneration = 0; + let eventSource: EventSource | null = null; + let streamedRunId: string | null = null; + + function invalidateRun() { + runGeneration += 1; + eventSource?.close(); + eventSource = null; + streamedRunId = null; + analyzing.value = false; + busy.value = false; + canceling.value = false; + saving.value = false; + loading.value = false; + return runGeneration; + } + + function reset() { + thread.value = null; + events.value = []; + generatedText.value = ""; + draftTitle.value = ""; + draftContent.value = ""; + error.value = ""; + } + + function isCurrent(token: number, threadId: string, runId?: string) { + return token === runGeneration + && thread.value?.threadId === threadId + && (!runId || thread.value.runId === runId); + } + + function applyThread(value: AgentThread, preserveAnswer = false) { + const changedThread = thread.value?.threadId !== value.threadId; + const changedDraft = thread.value?.draft.draftVersion !== value.draft.draftVersion; + thread.value = value; + draftTitle.value = value.draft.title; + draftContent.value = value.draft.content; + if (typeof value.analysis?.answer === "string") { + generatedText.value = value.analysis.answer; + } else if (!preserveAnswer && (changedThread || changedDraft)) { + generatedText.value = ""; + } + } + + function connectStream(value: AgentThread, token: number) { + if (streamedRunId === value.runId) return; + eventSource?.close(); + streamedRunId = value.runId; + const stream = new EventSource(agentApi.eventStreamUrl(value.threadId), { + withCredentials: true, + }); + eventSource = stream; + const current = () => isCurrent(token, value.threadId, value.runId); + const close = () => { + stream.close(); + if (eventSource === stream) eventSource = null; + }; + const parse = (event: Event) => { + try { + const value = JSON.parse((event as MessageEvent).data) as unknown; + return value && typeof value === "object" ? value as Record : null; + } catch { + error.value = "streamFailed"; + return null; + } + }; + + stream.addEventListener("workflow", (event) => { + if (!current()) return; + const payload = parse(event); + if (!payload || payload.runId !== value.runId || typeof payload.seq !== "number" + || typeof payload.kind !== "string" || !payload.detail || typeof payload.detail !== "object") return; + if (!events.value.some((item) => item.seq === payload.seq)) { + events.value = [...events.value, payload as unknown as AgentWorkflowEvent] + .sort((left, right) => left.seq - right.seq); + } + }); + stream.addEventListener("text", (event) => { + if (!current()) return; + const payload = parse(event); + if (payload?.runId === value.runId && typeof payload.text === "string") { + generatedText.value = payload.text; + } + }); + stream.addEventListener("terminal", (event) => { + if (!current()) return; + const payload = parse(event); + if (payload?.runId !== value.runId) return; + close(); + void refresh(value.threadId, token, value.runId); + }); + stream.addEventListener("stream_closed", (event) => { + if (!current()) return; + const payload = parse(event); + close(); + if (payload?.reason === "stream_timeout") error.value = "streamTimedOut"; + if (payload?.reason === "run_replaced") error.value = "runReplaced"; + void refresh(value.threadId, token, value.runId); + }); + stream.onerror = () => { + if (!current()) return; + close(); + error.value = "streamFailed"; + void refresh(value.threadId, token, value.runId); + }; + } + + async function refresh(threadId: string, token = runGeneration, runId?: string) { + try { + const value = await agentApi.getThread(threadId); + if (isCurrent(token, threadId, runId)) { + applyThread(value, true); + if (value.runId !== runId && (value.status === "analyzing" || value.status === "saving")) { + connectStream(value, token); + } + } + } catch { + if (token === runGeneration) error.value = "requestFailed"; + } + } + + async function loadThread(threadId: string) { + if (saving.value) return null; + const token = invalidateRun(); + reset(); + loading.value = true; + try { + const value = await agentApi.getThread(threadId); + if (token !== runGeneration) return null; + applyThread(value); + if (value.status === "analyzing" || value.status === "saving") connectStream(value, token); + return value; + } catch { + if (token === runGeneration) error.value = "requestFailed"; + return null; + } finally { + if (token === runGeneration) loading.value = false; + } + } + + async function createThread(sourceSubmissionId: string, question: string) { + const token = invalidateRun(); + reset(); + busy.value = true; + try { + const value = await agentApi.createThread(sourceSubmissionId, question); + if (token !== runGeneration) return null; + applyThread(value); + return value; + } catch { + if (token === runGeneration) error.value = "requestFailed"; + return null; + } finally { + if (token === runGeneration) busy.value = false; + } + } + + async function analyze() { + if (!thread.value) return; + const { threadId, runId: previousRunId } = thread.value; + const token = invalidateRun(); + events.value = []; + generatedText.value = ""; + analyzing.value = true; + busy.value = true; + error.value = ""; + const pending = agentApi.analyze(threadId).then( + (value) => ({ ok: true as const, value }), + () => ({ ok: false as const }), + ); + let outcome: Awaited | null = null; + while (!outcome && token === runGeneration) { + outcome = await Promise.race([pending, wait(250).then(() => null)]); + if (outcome || token !== runGeneration) break; + try { + const latest = await agentApi.getThread(threadId); + if (!isCurrent(token, threadId)) break; + applyThread(latest, true); + if (latest.runId !== previousRunId) connectStream(latest, token); + } catch { + if (token === runGeneration) error.value = "requestFailed"; + } + } + if (token !== runGeneration) return; + if (outcome?.ok) { + applyThread(outcome.value, true); + if (outcome.value.runId !== previousRunId) connectStream(outcome.value, token); + } else if (outcome) { + error.value = "requestFailed"; + await refresh(threadId, token); + } + if (token === runGeneration) { + analyzing.value = false; + busy.value = false; + } + } + + async function updateDraft() { + if (!thread.value) return; + const token = runGeneration; + const current = thread.value; + busy.value = true; + error.value = ""; + try { + const value = await agentApi.updateDraft(current.threadId, { + draftVersion: current.draft.draftVersion, + title: draftTitle.value, + content: draftContent.value, + }); + if (isCurrent(token, current.threadId)) applyThread(value); + } catch { + if (token === runGeneration) error.value = "requestFailed"; + } finally { + if (token === runGeneration) busy.value = false; + } + } + + async function confirmDraft() { + if (!thread.value || busy.value) return; + const token = runGeneration; + const current = thread.value; + busy.value = true; + error.value = ""; + try { + const value = await agentApi.confirm(current.threadId, current.draft, current.paramsDigest); + if (isCurrent(token, current.threadId)) applyThread(value); + } catch { + if (token === runGeneration) { + error.value = "requestFailed"; + await refresh(current.threadId, token); + } + } finally { + if (token === runGeneration) busy.value = false; + } + } + + async function save() { + const confirmationId = thread.value?.confirmation?.id; + if (!thread.value || !confirmationId || busy.value) return; + const token = runGeneration; + const current = thread.value; + busy.value = true; + saving.value = true; + error.value = ""; + try { + const value = await agentApi.save(current.threadId, confirmationId); + if (isCurrent(token, current.threadId)) applyThread(value); + } catch { + if (token === runGeneration) { + error.value = "requestFailed"; + await refresh(current.threadId, token); + } + } finally { + if (token === runGeneration) { + busy.value = false; + saving.value = false; + } + } + } + + async function recover() { + if (!thread.value) return; + const token = runGeneration; + const current = thread.value; + busy.value = true; + error.value = ""; + try { + const value = await agentApi.recover(current.threadId); + if (isCurrent(token, current.threadId)) applyThread(value); + } catch { + if (token === runGeneration) { + error.value = "requestFailed"; + await refresh(current.threadId, token); + } + } finally { + if (token === runGeneration) busy.value = false; + } + } + + async function cancel() { + if (!thread.value || saving.value || thread.value.status === "saving") return; + const token = invalidateRun(); + const current = thread.value; + canceling.value = true; + error.value = ""; + try { + const value = await agentApi.cancel(current.threadId); + if (isCurrent(token, current.threadId)) applyThread(value, true); + } catch { + if (token === runGeneration) { + error.value = "requestFailed"; + await refresh(current.threadId, token); + } + } finally { + if (token === runGeneration) canceling.value = false; + } + } + + function clear() { + if (saving.value || thread.value?.status === "saving") return false; + invalidateRun(); + reset(); + return true; + } + + onScopeDispose(invalidateRun); + + return { + thread, events, generatedText, draftTitle, draftContent, error, + busy, loading, analyzing, canceling, saving, + loadThread, createThread, analyze, updateDraft, confirmDraft, save, recover, cancel, clear, + }; +} diff --git a/apps/console/src/i18n/locales/en-US/agent.ts b/apps/console/src/i18n/locales/en-US/agent.ts new file mode 100644 index 0000000000..a81e18e97b --- /dev/null +++ b/apps/console/src/i18n/locales/en-US/agent.ts @@ -0,0 +1,52 @@ +export default { + title: "Agent learning-plan demo", + description: "Create an analysis thread from your own submission. Analysis, confirmation, and saving are separate steps.", + sourceSubmissionId: "Submission ID", + question: "What would you like to learn?", + questionPlaceholder: "For example: what should I review about this submission?", + start: "Create analysis thread", + newThread: "New thread", + refresh: "Reload thread", + thread: "Thread", + status: "Current status", + draft: "Learning plan draft", + draftTitle: "Title", + draftContent: "Content", + updateDraft: "Update draft", + analyze: "Start analysis", + confirm: "Confirm this draft", + save: "Save to UltiCode", + cancel: "Cancel thread", + recover: "Check save result", + generated: "Generated analysis", + citations: "Citations", + citationChecks: "Citation checks", + source: "Source location", + quote: "Source excerpt", + toolEvents: "Tool activity", + toolStarted: "Started", + toolCompleted: "Completed", + waitingConfirmation: "Confirmation records your approval; only the separate save step writes to UltiCode.", + savingWarning: "Saving is in progress. Cancelling cannot undo a write already sent to UltiCode.", + savedPlan: "Saved plan ID: {id}", + noCitations: "No citations to display.", + statuses: { + idle: "Waiting for input", + loading: "Restoring thread", + creating: "Creating thread", + awaiting_confirmation: "Awaiting confirmation", + analyzing: "Generating and checking", + confirmed: "Confirmed; save is a separate step", + saving: "Saving", + saved: "Completed", + unknown: "Save result unknown", + failed: "Failed", + cancelled: "Cancelled", + }, + errors: { + requestFailed: "The request did not complete. Check the service connection, then reload the thread to see its canonical state.", + streamFailed: "The event stream disconnected. The server remains the source of truth; reload the thread.", + streamTimedOut: "The event stream timed out. The server remains the source of truth; reload the thread.", + runReplaced: "This event stream was replaced by a newer run; the page now shows the server's current state.", + }, +} as const; diff --git a/apps/console/src/i18n/locales/en-US/index.ts b/apps/console/src/i18n/locales/en-US/index.ts index 19f6bb2cb8..9e8a812d38 100644 --- a/apps/console/src/i18n/locales/en-US/index.ts +++ b/apps/console/src/i18n/locales/en-US/index.ts @@ -14,6 +14,7 @@ import solution from "./solution"; import shortcuts from "./shortcuts"; import achievement from "./achievement"; import landing from "./landing"; +import agent from "./agent"; export default { common, auth, @@ -31,4 +32,5 @@ export default { shortcuts, achievement, landing, + agent, } as const; diff --git a/apps/console/src/i18n/locales/zh-CN/agent.ts b/apps/console/src/i18n/locales/zh-CN/agent.ts new file mode 100644 index 0000000000..4d6facc9f4 --- /dev/null +++ b/apps/console/src/i18n/locales/zh-CN/agent.ts @@ -0,0 +1,52 @@ +export default { + title: "Agent 学习计划演示", + description: "从你自己的提交创建分析线程。分析、确认和保存是独立步骤。", + sourceSubmissionId: "提交 ID", + question: "你想了解什么?", + questionPlaceholder: "例如:这次提交有哪些值得复盘的地方?", + start: "创建分析线程", + newThread: "新建线程", + refresh: "重新载入线程", + thread: "线程", + status: "当前状态", + draft: "待确认的学习计划", + draftTitle: "标题", + draftContent: "内容", + updateDraft: "更新草稿", + analyze: "开始分析", + confirm: "确认此草稿", + save: "保存到 UltiCode", + cancel: "取消线程", + recover: "核对保存结果", + generated: "生成的分析文本", + citations: "引用来源", + citationChecks: "引用核验", + source: "来源位置", + quote: "原文摘录", + toolEvents: "工具执行记录", + toolStarted: "开始执行", + toolCompleted: "执行完成", + waitingConfirmation: "确认只记录你的授权;下一步保存才会调用 UltiCode。", + savingWarning: "正在保存。此时取消不能撤销已经发送到 UltiCode 的写入。", + savedPlan: "已保存的计划 ID:{id}", + noCitations: "没有可显示的引用。", + statuses: { + idle: "等待输入", + loading: "正在恢复线程", + creating: "正在创建线程", + awaiting_confirmation: "待确认", + analyzing: "正在生成与核验", + confirmed: "已确认,等待单独保存", + saving: "正在保存", + saved: "已完成", + unknown: "保存结果未知", + failed: "失败", + cancelled: "已取消", + }, + errors: { + requestFailed: "请求未能完成。请检查服务连接并重新载入线程以查看规范状态。", + streamFailed: "事件流断开。线程状态仍以服务端记录为准,请重新载入。", + streamTimedOut: "事件流已超时。线程状态仍以服务端记录为准,请重新载入。", + runReplaced: "此事件流对应的运行已被替换;页面正在显示服务端当前状态。", + }, +} as const; diff --git a/apps/console/src/i18n/locales/zh-CN/index.ts b/apps/console/src/i18n/locales/zh-CN/index.ts index 19f6bb2cb8..9e8a812d38 100644 --- a/apps/console/src/i18n/locales/zh-CN/index.ts +++ b/apps/console/src/i18n/locales/zh-CN/index.ts @@ -14,6 +14,7 @@ import solution from "./solution"; import shortcuts from "./shortcuts"; import achievement from "./achievement"; import landing from "./landing"; +import agent from "./agent"; export default { common, auth, @@ -31,4 +32,5 @@ export default { shortcuts, achievement, landing, + agent, } as const; diff --git a/apps/console/src/router/index.ts b/apps/console/src/router/index.ts index 9ba2a8ea23..f7e5b63126 100644 --- a/apps/console/src/router/index.ts +++ b/apps/console/src/router/index.ts @@ -221,6 +221,11 @@ const personalRoutes: RouteRecordRaw = { name: "personal-submissions", component: () => import("@/views/personal/SubmissionsView.vue"), }, + { + path: "agent", + name: "personal-agent-demo", + component: () => import("@/views/personal/AgentDemoView.vue"), + }, { path: "solutions", name: "personal-solutions", diff --git a/apps/console/src/views/personal/AgentDemoView.vue b/apps/console/src/views/personal/AgentDemoView.vue new file mode 100644 index 0000000000..1682d09214 --- /dev/null +++ b/apps/console/src/views/personal/AgentDemoView.vue @@ -0,0 +1,276 @@ + + +