diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 1eebfc69d..c409ef819 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,3 +1,5 @@ pnpm-lock.yaml .omc/ src/content/**/*.mdx +playwright-report/ +test-results/ diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 93130c44f..46ae18441 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -8,7 +8,7 @@ import { SquareArrowOutUpRightIcon, XIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -30,8 +30,16 @@ import { import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { useArtifactContent } from "@/core/artifacts/hooks"; +import { + appendHtmlPreviewBaseHref, + appendHtmlPreviewScrollRestoration, + createHtmlPreviewScrollKey, + getArtifactViewState, + HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, +} from "@/core/artifacts/preview"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; +import { findToolCallResult } from "@/core/messages/utils"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; @@ -44,6 +52,8 @@ import { Tooltip } from "../tooltip"; import { useArtifacts } from "./context"; +const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000; + export function ArtifactFileDetail({ className, filepath: filepathFromProps, @@ -55,6 +65,7 @@ export function ArtifactFileDetail({ }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); + const { thread, isMock } = useThread(); const isWriteFile = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); @@ -83,6 +94,22 @@ export function ArtifactFileDetail({ const isSupportPreview = useMemo(() => { return language === "html" || language === "markdown"; }, [language]); + const toolResult = (() => { + if (!isWriteFile) { + return undefined; + } + const url = new URL(filepathFromProps); + const toolCallId = url.searchParams.get("tool_call_id"); + if (!toolCallId) { + return undefined; + } + return findToolCallResult(toolCallId, thread.messages); + })(); + const artifactViewState = getArtifactViewState({ + filepath: filepathFromProps, + isSupportPreview, + toolResult, + }); const { content, url } = useArtifactContent({ threadId, filepath: filepathFromProps, @@ -90,17 +117,20 @@ export function ArtifactFileDetail({ }); const displayContent = content ?? ""; + const isWritingFile = isWriteFile && toolResult === undefined; + const visibleContent = useThrottledValue( + displayContent, + isWritingFile ? WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS : 0, + filepathFromProps, + ); - const [viewMode, setViewMode] = useState<"code" | "preview">("code"); + const [viewMode, setViewMode] = useState<"code" | "preview">( + artifactViewState.initialViewMode, + ); const [isInstalling, setIsInstalling] = useState(false); - const { isMock } = useThread(); useEffect(() => { - if (isSupportPreview) { - setViewMode("preview"); - } else { - setViewMode("code"); - } - }, [isSupportPreview]); + setViewMode(artifactViewState.initialViewMode); + }, [artifactViewState.initialViewMode]); const handleInstallSkill = useCallback(async () => { if (isInstalling) return; @@ -149,7 +179,7 @@ export function ArtifactFileDetail({
测试内容
", + toolResult, +}: { + path?: string; + content?: string; + toolResult?: string; +} = {}) { + const messages: unknown[] = [ + { + type: "human", + id: "msg-human-artifact", + content: [{ type: "text", text: "Create a report artifact" }], + }, + { + type: "ai", + id: "msg-ai-write-artifact", + content: "", + tool_calls: [ + { + id: "write-file-artifact", + name: "write_file", + args: { + description: "Writing report artifact", + path, + content, + }, + }, + ], + }, + ]; + + if (toolResult !== undefined) { + messages.push({ + type: "tool", + id: "msg-tool-write-artifact", + name: "write_file", + tool_call_id: "write-file-artifact", + content: toolResult, + }); + } + + return messages; +} + +test.describe("Artifact preview stability", () => { + test("renders preview iframe for an in-progress write artifact", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: IN_PROGRESS_THREAD_ID, + title: "Artifact preview in progress", + messages: writeFileMessages(), + }, + ], + }); + + await page.goto(`/workspace/chats/${IN_PROGRESS_THREAD_ID}`); + + await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({ + timeout: 15_000, + }); + await page.getByText(ARTIFACT_PATH).click(); + + const artifactsPanel = page.locator("#artifacts"); + await expect(artifactsPanel.getByText("report.html")).toBeVisible(); + await expect( + artifactsPanel.locator('iframe[title="Artifact preview"]'), + ).toBeVisible(); + }); + + test("renders preview iframe after the write artifact succeeds", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: COMPLETE_THREAD_ID, + title: "Artifact preview complete", + messages: writeFileMessages({ toolResult: "OK" }), + }, + ], + }); + + await page.goto(`/workspace/chats/${COMPLETE_THREAD_ID}`); + + await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({ + timeout: 15_000, + }); + await page.getByText(ARTIFACT_PATH).click(); + + const artifactsPanel = page.locator("#artifacts"); + await expect(artifactsPanel.getByText("report.html")).toBeVisible(); + await expect( + artifactsPanel.locator('iframe[title="Artifact preview"]'), + ).toBeVisible(); + }); + + test("renders markdown preview for an in-progress write artifact", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MARKDOWN_THREAD_ID, + title: "Markdown artifact preview in progress", + messages: writeFileMessages({ + path: MARKDOWN_ARTIFACT_PATH, + content: "# Markdown draft\n\n- 测试内容 1\n- English term", + }), + }, + ], + }); + + await page.goto(`/workspace/chats/${MARKDOWN_THREAD_ID}`); + + await expect(page.getByText(MARKDOWN_ARTIFACT_PATH)).toBeVisible({ + timeout: 15_000, + }); + await page.getByText(MARKDOWN_ARTIFACT_PATH).click(); + + const artifactsPanel = page.locator("#artifacts"); + await expect(artifactsPanel.getByText("report.md")).toBeVisible(); + await expect(artifactsPanel.getByText("Markdown draft")).toBeVisible(); + await expect(artifactsPanel.getByText("测试内容 1")).toBeVisible(); + }); + + test("renders code view for an in-progress non-preview write artifact", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: JSON_THREAD_ID, + title: "JSON artifact code view in progress", + messages: writeFileMessages({ + path: JSON_ARTIFACT_PATH, + content: + '{\n "status": "draft",\n "中文字段": "测试内容",\n "count": 3\n}', + }), + }, + ], + }); + + await page.goto(`/workspace/chats/${JSON_THREAD_ID}`); + + await expect(page.getByText(JSON_ARTIFACT_PATH)).toBeVisible({ + timeout: 15_000, + }); + await page.getByText(JSON_ARTIFACT_PATH).click(); + + const artifactsPanel = page.locator("#artifacts"); + await expect(artifactsPanel.getByText("report.json")).toBeVisible(); + await expect(artifactsPanel.getByText('"status": "draft"')).toBeVisible(); + await expect( + artifactsPanel.getByText('"中文字段": "测试内容"'), + ).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index e2d515329..cf10db08b 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -25,6 +25,8 @@ export type MockThread = { title?: string; updated_at?: string; agent_name?: string; + messages?: unknown[]; + artifacts?: string[]; }; export type MockAgent = { @@ -113,7 +115,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { { values: { title: matchingThread.title ?? "Untitled", - messages: [ + messages: matchingThread.messages ?? [ { type: "human", id: `msg-human-${matchingThread.thread_id}`, @@ -125,6 +127,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, }, ], + artifacts: matchingThread.artifacts ?? [], }, next: [], metadata: {}, @@ -155,7 +158,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { values: { title: matchingThread?.title ?? "Untitled", messages: matchingThread - ? [ + ? (matchingThread.messages ?? [ { type: "human", id: `msg-human-${matchingThread.thread_id}`, @@ -166,8 +169,9 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { id: `msg-ai-${matchingThread.thread_id}`, content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, }, - ] + ]) : [], + artifacts: matchingThread?.artifacts ?? [], }, next: [], metadata: {}, @@ -183,15 +187,59 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { // followed by `?` or end-of-string. This must NOT match `/runs/stream`. void page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (route) => { if (route.request().method() === "GET") { + const url = route.request().url(); + const matchingThread = threads.find((t) => url.includes(t.thread_id)); return route.fulfill({ status: 200, contentType: "application/json", - body: "[]", + body: JSON.stringify( + matchingThread + ? [ + { + run_id: `run-${matchingThread.thread_id}`, + thread_id: matchingThread.thread_id, + assistant_id: "lead_agent", + status: "success", + metadata: {}, + kwargs: {}, + created_at: "2025-01-01T00:00:00Z", + updated_at: + matchingThread.updated_at ?? "2025-01-01T00:00:00Z", + }, + ] + : [], + ), }); } return route.fallback(); }); + void page.route( + /\/api\/threads\/([^/]+)\/runs\/([^/]+)\/messages/, + (route) => { + if (route.request().method() === "GET") { + const url = route.request().url(); + const matchingThread = threads.find((t) => + url.includes(`/api/threads/${t.thread_id}/runs/`), + ); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: (matchingThread?.messages ?? []).map((message, index) => ({ + run_id: `run-${matchingThread?.thread_id ?? "unknown"}`, + content: message, + metadata: { caller: "lead_agent" }, + created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`, + })), + hasMore: false, + }), + }); + } + return route.fallback(); + }, + ); + // Run stream — returns a minimal SSE response with an AI message void page.route("**/api/langgraph/runs/stream", handleRunStream); void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream); diff --git a/frontend/tests/unit/core/artifacts/preview.test.ts b/frontend/tests/unit/core/artifacts/preview.test.ts new file mode 100644 index 000000000..123f6bca4 --- /dev/null +++ b/frontend/tests/unit/core/artifacts/preview.test.ts @@ -0,0 +1,310 @@ +import { expect, test } from "vitest"; + +import { + appendHtmlPreviewBaseHref, + appendHtmlPreviewScrollRestoration, + buildWriteFileDraftContent, + createHtmlPreviewScrollKey, + getArtifactViewState, +} from "@/core/artifacts/preview"; + +const ARTIFACT_PATH = "/artifact-fixtures/report.html"; +const UNSUPPORTED_ARTIFACT_PATH = "/artifact-fixtures/data.csv"; + +test("allows in-progress write artifacts to render a throttled preview", () => { + expect( + getArtifactViewState({ + filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`, + isSupportPreview: true, + }), + ).toEqual({ + canPreview: true, + initialViewMode: "preview", + }); +}); + +test("allows preview for a write artifact once the tool call has a result", () => { + expect( + getArtifactViewState({ + filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`, + isSupportPreview: true, + toolResult: "OK", + }), + ).toEqual({ + canPreview: true, + initialViewMode: "preview", + }); +}); + +test("keeps failed write artifacts in code view", () => { + expect( + getArtifactViewState({ + filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`, + isSupportPreview: true, + toolResult: "Error: Failed to write file", + }), + ).toEqual({ + canPreview: false, + initialViewMode: "code", + }); +}); + +test("keeps completed artifacts on their existing preview defaults", () => { + expect( + getArtifactViewState({ + filepath: ARTIFACT_PATH, + isSupportPreview: true, + }), + ).toEqual({ + canPreview: true, + initialViewMode: "preview", + }); +}); + +test("keeps unsupported artifacts in code view", () => { + expect( + getArtifactViewState({ + filepath: UNSUPPORTED_ARTIFACT_PATH, + isSupportPreview: false, + }), + ).toEqual({ + canPreview: false, + initialViewMode: "code", + }); +}); + +test("builds a draft write-file artifact from successful writes plus the selected in-progress append", () => { + const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-2&tool_call_id=call-2`; + + expect( + buildWriteFileDraftContent({ + filepath, + messages: [ + { + type: "ai", + id: "ai-1", + tool_calls: [ + { + id: "call-1", + name: "write_file", + args: { + path: ARTIFACT_PATH, + content: "", + }, + }, + ], + }, + { + type: "tool", + id: "tool-1", + name: "write_file", + tool_call_id: "call-1", + content: "OK", + }, + { + type: "ai", + id: "ai-2", + tool_calls: [ + { + id: "call-2", + name: "write_file", + args: { + append: true, + path: ARTIFACT_PATH, + content: "追加内容
", + }, + }, + ], + }, + ], + }), + ).toBe("追加内容
"); +}); + +test("does not include failed writes in a draft artifact", () => { + const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-3&tool_call_id=call-3`; + + expect( + buildWriteFileDraftContent({ + filepath, + messages: [ + { + type: "ai", + id: "ai-1", + tool_calls: [ + { + id: "call-1", + name: "write_file", + args: { + path: ARTIFACT_PATH, + content: "", + }, + }, + ], + }, + { + type: "tool", + id: "tool-1", + name: "write_file", + tool_call_id: "call-1", + content: "OK", + }, + { + type: "ai", + id: "ai-2", + tool_calls: [ + { + id: "call-2", + name: "write_file", + args: { + append: true, + path: ARTIFACT_PATH, + content: "失败内容
", + }, + }, + ], + }, + { + type: "tool", + id: "tool-2", + name: "write_file", + tool_call_id: "call-2", + content: "Error: write failed", + }, + { + type: "ai", + id: "ai-3", + tool_calls: [ + { + id: "call-3", + name: "write_file", + args: { + append: true, + path: ARTIFACT_PATH, + content: "", + }, + }, + ], + }, + ], + }), + ).toBe(""); +}); + +test("returns undefined when the selected append failed so the caller can fall back", () => { + const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-2&tool_call_id=call-2`; + + expect( + buildWriteFileDraftContent({ + filepath, + messages: [ + { + type: "ai", + id: "ai-1", + tool_calls: [ + { + id: "call-1", + name: "write_file", + args: { + path: ARTIFACT_PATH, + content: "", + }, + }, + ], + }, + { + type: "tool", + id: "tool-1", + name: "write_file", + tool_call_id: "call-1", + content: "OK", + }, + { + type: "ai", + id: "ai-2", + tool_calls: [ + { + id: "call-2", + name: "write_file", + args: { + append: true, + path: ARTIFACT_PATH, + content: "失败的追加内容
", + }, + }, + ], + }, + { + type: "tool", + id: "tool-2", + name: "write_file", + tool_call_id: "call-2", + content: "Error: write failed", + }, + ], + }), + ).toBeUndefined(); +}); + +test("injects scroll restoration at the start of the HTML head", () => { + const html = + '