diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3222ae4..cd7bcf0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,6 +2,8 @@ import type { Chapter, + ChangeSet, + ChangeSetApplyResult, Character, CharacterRelationship, HealthStatus, @@ -238,6 +240,21 @@ export const apiClient = { unwrap( api.put(`/scenes/${sceneId}/working-draft`, payload), ), + listChangeSets: (sceneId: string) => + unwrap(api.get(`/scenes/${sceneId}/change-sets`)), + getChangeSet: (changeSetId: string) => + unwrap(api.get(`/change-sets/${changeSetId}`)), + applyChangeSet: ( + changeSetId: string, + payload: { + expected_draft_revision: number; + accept_operation_ids: string[]; + reject_operation_ids: string[]; + }, + ) => + unwrap( + api.post(`/change-sets/${changeSetId}/apply`, payload), + ), approveVersion: ( sceneId: string, versionId: string, diff --git a/frontend/src/features/workspace/ChangeSetReviewPanel.test.tsx b/frontend/src/features/workspace/ChangeSetReviewPanel.test.tsx new file mode 100644 index 0000000..af03c07 --- /dev/null +++ b/frontend/src/features/workspace/ChangeSetReviewPanel.test.tsx @@ -0,0 +1,214 @@ +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { apiClient } from "../../api/client"; +import type { + ChangeOperation, + ChangeSet, + SceneWorkingDraft, +} from "../../types/entities"; +import { ChangeSetReviewPanel } from "./ChangeSetReviewPanel"; + +vi.mock("../../api/client", () => ({ + apiClient: { + listChangeSets: vi.fn(), + getWorkingDraft: vi.fn(), + applyChangeSet: vi.fn(), + }, +})); + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +function renderWithQuery(ui: ReactElement) { + return render( + + {ui} + , + ); +} + +const now = "2026-07-20T08:00:00.000Z"; +const originalBlock = { + type: "paragraph", + attrs: { nodeId: "node-1" }, + content: [{ type: "text", text: "他推开旧书店的门。" }], +}; +const proposedBlock = { + type: "paragraph", + attrs: { nodeId: "node-1" }, + content: [{ type: "text", text: "他在雨声中推开旧书店的门。" }], +}; + +const operation: ChangeOperation = { + id: "operation-1", + change_set_id: "change-set-1", + sequence_no: 1, + operation_type: "replace_block", + target_node_id: "node-1", + anchor_before_node_id: null, + anchor_after_node_id: null, + original_json: originalBlock, + proposed_json: proposedBlock, + original_hash: "a".repeat(64), + status: "pending", + accepted_draft_revision: null, + conflict_reason: "", + created_at: now, + updated_at: now, +}; + +const changeSet: ChangeSet = { + id: "change-set-1", + scene_id: "scene-1", + base_working_revision: 3, + base_document_hash: "b".repeat(64), + base_version_id: "version-1", + purpose: "rewrite", + status: "pending", + workflow_run_id: null, + summary: "增强场景开头的氛围。", + applied_version_id: null, + operations: [operation], + created_at: now, + updated_at: now, +}; + +const workingDraft: SceneWorkingDraft = { + scene_id: "scene-1", + content_json: { + type: "doc", + content: [originalBlock], + }, + content_markdown: "他推开旧书店的门。", + revision: 3, + updated_at: now, +}; + +const appliedDraft: SceneWorkingDraft = { + ...workingDraft, + content_json: { + type: "doc", + content: [proposedBlock], + }, + content_markdown: "他在雨声中推开旧书店的门。", + revision: 4, +}; + +describe("ChangeSetReviewPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.listChangeSets).mockResolvedValue([changeSet]); + vi.mocked(apiClient.getWorkingDraft).mockResolvedValue(workingDraft); + vi.mocked(apiClient.applyChangeSet).mockResolvedValue({ + change_set: { + ...changeSet, + status: "accepted", + operations: [ + { + ...operation, + status: "accepted", + accepted_draft_revision: 4, + }, + ], + }, + draft: appliedDraft, + }); + }); + + it("previews original and proposed blocks and applies one decision", async () => { + const onDraftApplied = vi.fn(); + renderWithQuery( + , + ); + + expect(await screen.findByText("他推开旧书店的门。")).toBeInTheDocument(); + expect(screen.getByText("他在雨声中推开旧书店的门。")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "接受" })); + + await waitFor(() => { + expect(apiClient.applyChangeSet).toHaveBeenCalledWith("change-set-1", { + expected_draft_revision: 3, + accept_operation_ids: ["operation-1"], + reject_operation_ids: [], + }); + }); + expect(onDraftApplied).toHaveBeenCalledWith(appliedDraft); + expect( + await screen.findByText("改动已应用到工作草稿。"), + ).toBeInTheDocument(); + }); + + it("blocks all decisions while the editor has unsaved content", async () => { + renderWithQuery( + , + ); + + expect(await screen.findByText("正文还有未保存改动。")).toBeInTheDocument(); + await screen.findByText("增强场景开头的氛围。"); + expect(screen.getByRole("button", { name: "接受" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "接受全部待处理" }), + ).toBeDisabled(); + }); + + it("rejects all pending operations without applying content", async () => { + vi.mocked(apiClient.applyChangeSet).mockResolvedValue({ + change_set: { + ...changeSet, + status: "rejected", + operations: [{ ...operation, status: "rejected" }], + }, + draft: null, + }); + + renderWithQuery(); + + await screen.findByText("增强场景开头的氛围。"); + fireEvent.click(screen.getByRole("button", { name: "拒绝全部待处理" })); + + await waitFor(() => { + expect(apiClient.applyChangeSet).toHaveBeenCalledWith("change-set-1", { + expected_draft_revision: 3, + accept_operation_ids: [], + reject_operation_ids: ["operation-1"], + }); + }); + expect(await screen.findByText("审阅决定已保存。")).toBeInTheDocument(); + }); + + it("refreshes the draft and proposals after a revision conflict", async () => { + vi.mocked(apiClient.applyChangeSet).mockRejectedValue({ + isAxiosError: true, + response: { + status: 409, + data: { details: { reason: "DRAFT_REVISION_CONFLICT" } }, + }, + }); + + renderWithQuery(); + + await screen.findByText("增强场景开头的氛围。"); + fireEvent.click(screen.getByRole("button", { name: "接受" })); + + expect( + await screen.findByText("草稿已在别处变化,已刷新最新内容,请重新审阅。"), + ).toBeInTheDocument(); + await waitFor(() => { + expect(apiClient.getWorkingDraft).toHaveBeenCalledTimes(2); + expect(apiClient.listChangeSets).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/frontend/src/features/workspace/ChangeSetReviewPanel.tsx b/frontend/src/features/workspace/ChangeSetReviewPanel.tsx new file mode 100644 index 0000000..44e5aa7 --- /dev/null +++ b/frontend/src/features/workspace/ChangeSetReviewPanel.tsx @@ -0,0 +1,388 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import { Check, RefreshCw, X } from "lucide-react"; + +import { apiClient } from "../../api/client"; +import { IconButton } from "../../components/IconButton"; +import { StatusPill } from "../../components/StatusPill"; +import type { + ChangeOperation, + ChangeSet, + SceneWorkingDraft, +} from "../../types/entities"; +import { RichTextCodec, type RichTextNode } from "../../utils/richTextCodec"; + +interface Props { + sceneId: string; + disabledReason?: string; + onDraftApplied?: (draft: SceneWorkingDraft) => void; +} + +interface Decision { + changeSetId: string; + acceptIds: string[]; + rejectIds: string[]; +} + +const PURPOSE_LABELS: Record = { + generation: "生成", + rewrite: "改写", + review_fix: "审查修复", + restore: "恢复", + merge: "合并", +}; + +const CHANGE_SET_STATUS_LABELS: Record = { + pending: "待处理", + partially_accepted: "部分已应用", + accepted: "已接受", + rejected: "已拒绝", + conflicted: "存在冲突", +}; + +const OPERATION_LABELS: Record = { + insert_before: "在前方插入", + insert_after: "在后方插入", + replace_block: "替换段落", + delete_block: "删除段落", +}; + +const OPERATION_STATUS_LABELS: Record = { + pending: "待决定", + accepted: "已接受", + rejected: "已拒绝", + conflicted: "冲突", + orphaned: "目标丢失", +}; + +function renderBlock( + value: Record, + emptyLabel: string, +): string { + if (!value.type) { + return emptyLabel; + } + try { + return RichTextCodec.toMarkdown({ + type: "doc", + content: [value as unknown as RichTextNode], + }); + } catch { + return JSON.stringify(value, null, 2); + } +} + +function operationPreview(operation: ChangeOperation) { + if ( + operation.operation_type === "insert_before" || + operation.operation_type === "insert_after" + ) { + return { + original: "(新增内容)", + proposed: renderBlock(operation.proposed_json, "(空内容)"), + }; + } + if (operation.operation_type === "delete_block") { + return { + original: renderBlock(operation.original_json, "(原段落不可用)"), + proposed: "(删除该段落)", + }; + } + return { + original: renderBlock(operation.original_json, "(原段落不可用)"), + proposed: renderBlock(operation.proposed_json, "(空内容)"), + }; +} + +function errorReason(error: unknown): string { + if (!axios.isAxiosError(error)) { + return ""; + } + const data = error.response?.data as + | { details?: { reason?: string } } + | undefined; + return data?.details?.reason ?? ""; +} + +export function ChangeSetReviewPanel({ + sceneId, + disabledReason = "", + onDraftApplied, +}: Props) { + const queryClient = useQueryClient(); + const [selectedChangeSetId, setSelectedChangeSetId] = useState(""); + const [message, setMessage] = useState(""); + + const changeSetsQuery = useQuery({ + queryKey: ["change-sets", sceneId], + queryFn: () => apiClient.listChangeSets(sceneId), + enabled: Boolean(sceneId), + }); + const draftQuery = useQuery({ + queryKey: ["scene-working-draft", sceneId], + queryFn: () => apiClient.getWorkingDraft(sceneId), + enabled: Boolean(sceneId), + }); + + const changeSets = changeSetsQuery.data ?? []; + const activeChangeSetId = changeSets.some( + (item) => item.id === selectedChangeSetId, + ) + ? selectedChangeSetId + : (changeSets[0]?.id ?? ""); + const activeChangeSet = changeSets.find( + (item) => item.id === activeChangeSetId, + ); + + const applyDecision = useMutation({ + mutationFn: (decision: Decision) => + apiClient.applyChangeSet(decision.changeSetId, { + expected_draft_revision: draftQuery.data?.revision ?? 0, + accept_operation_ids: decision.acceptIds, + reject_operation_ids: decision.rejectIds, + }), + onSuccess: (result) => { + setMessage(result.draft ? "改动已应用到工作草稿。" : "审阅决定已保存。"); + queryClient.setQueryData( + ["change-sets", sceneId], + (current) => + current?.map((item) => + item.id === result.change_set.id ? result.change_set : item, + ) ?? [result.change_set], + ); + queryClient.setQueryData( + ["change-set", result.change_set.id], + result.change_set, + ); + if (result.draft) { + queryClient.setQueryData( + ["scene-working-draft", sceneId], + result.draft, + ); + onDraftApplied?.(result.draft); + } + }, + onError: (error) => { + if (errorReason(error) === "DRAFT_REVISION_CONFLICT") { + setMessage("草稿已在别处变化,已刷新最新内容,请重新审阅。"); + queryClient.invalidateQueries({ + queryKey: ["scene-working-draft", sceneId], + }); + queryClient.invalidateQueries({ + queryKey: ["change-sets", sceneId], + }); + return; + } + setMessage("改动处理失败,请刷新后重试。"); + }, + }); + + const isLoading = + changeSetsQuery.isLoading || + changeSetsQuery.isFetching || + draftQuery.isLoading || + draftQuery.isFetching; + const actionsDisabled = + Boolean(disabledReason) || + isLoading || + applyDecision.isPending || + !draftQuery.data; + const pendingOperations = + activeChangeSet?.operations.filter((item) => item.status === "pending") ?? + []; + + function decide(operation: ChangeOperation, accept: boolean) { + if (!activeChangeSet) return; + setMessage(""); + applyDecision.mutate({ + changeSetId: activeChangeSet.id, + acceptIds: accept ? [operation.id] : [], + rejectIds: accept ? [] : [operation.id], + }); + } + + function decideAll(accept: boolean) { + if (!activeChangeSet || pendingOperations.length === 0) return; + setMessage(""); + const ids = pendingOperations.map((item) => item.id); + applyDecision.mutate({ + changeSetId: activeChangeSet.id, + acceptIds: accept ? ids : [], + rejectIds: accept ? [] : ids, + }); + } + + function refresh() { + setMessage(""); + queryClient.invalidateQueries({ queryKey: ["change-sets", sceneId] }); + queryClient.invalidateQueries({ + queryKey: ["scene-working-draft", sceneId], + }); + } + + return ( +
+
+
+

改动审阅

+

+ 对 AI 提议逐项确认,接受后才写入工作草稿。 +

+
+ + {activeChangeSet + ? CHANGE_SET_STATUS_LABELS[activeChangeSet.status] + : "暂无改动"} + +
+ +
+ } + label="刷新" + onClick={refresh} + disabled={isLoading || !sceneId} + /> + } + label="接受全部待处理" + tone="primary" + onClick={() => decideAll(true)} + disabled={actionsDisabled || pendingOperations.length === 0} + /> + } + label="拒绝全部待处理" + onClick={() => decideAll(false)} + disabled={actionsDisabled || pendingOperations.length === 0} + /> +
+ + {disabledReason ? ( +
+ {disabledReason} +
+ ) : null} + {message ? ( +
+ {message} +
+ ) : null} + {changeSetsQuery.isError || draftQuery.isError ? ( +
+ 改动或草稿加载失败,请刷新后重试。 +
+ ) : null} + + {changeSets.length > 0 ? ( + + ) : null} + + {isLoading ? ( +
加载改动记录…
+ ) : null} + {!isLoading && changeSets.length === 0 ? ( +
+ 当前场景还没有待审阅的 AI 改动。 +
+ ) : null} + + {activeChangeSet ? ( +
+ {activeChangeSet.summary ? ( +

+ {activeChangeSet.summary} +

+ ) : null} + {activeChangeSet.operations.map((operation) => { + const preview = operationPreview(operation); + const isPending = operation.status === "pending"; + return ( +
+
+

+ #{operation.sequence_no}{" "} + {OPERATION_LABELS[operation.operation_type]} +

+ + {OPERATION_STATUS_LABELS[operation.status]} + +
+
+
+

原文

+
+                      {preview.original}
+                    
+
+
+

建议

+
+                      {preview.proposed}
+                    
+
+
+ {operation.conflict_reason ? ( +

+ 无法自动应用:{operation.conflict_reason} +

+ ) : null} + {isPending ? ( +
+ + +
+ ) : null} +
+ ); + })} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/features/workspace/SceneEditor.test.tsx b/frontend/src/features/workspace/SceneEditor.test.tsx index f60ee22..be59387 100644 --- a/frontend/src/features/workspace/SceneEditor.test.tsx +++ b/frontend/src/features/workspace/SceneEditor.test.tsx @@ -326,6 +326,81 @@ describe("SceneEditor approval gate", () => { }); }); + it("loads an accepted ChangeSet draft without creating a new version", async () => { + vi.mocked(apiClient.listVersions).mockResolvedValue([ + version("not_reviewed"), + ]); + const onContentChange = vi.fn(); + const rendered = renderWithQuery( + , + ); + await waitFor(() => { + expect(editorMock.commands.setContent).toHaveBeenCalled(); + }); + editorMock.commands.setContent.mockClear(); + + const appliedDocument = { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { nodeId: "71d414cc-b68e-454c-9414-c583f8fd71a4" }, + content: [{ type: "text", text: "已接受的改动" }], + }, + ], + }; + rendered.rerender( + + + , + ); + + await waitFor(() => { + expect(editorMock.commands.setContent).toHaveBeenCalledWith( + appliedDocument, + false, + ); + }); + expect(onContentChange).toHaveBeenLastCalledWith("已接受的改动"); + expect(apiClient.createVersion).not.toHaveBeenCalled(); + expect(screen.getByText("草稿已保存")).toBeInTheDocument(); + }); + + it("reports unsaved editor state to its parent", async () => { + vi.mocked(apiClient.listVersions).mockResolvedValue([]); + const onDirtyChange = vi.fn(); + renderWithQuery( + , + ); + await waitFor(() => { + expect(editorMock.commands.setContent).toHaveBeenCalled(); + }); + + editorMock.getJSON.mockReturnValue({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "尚未保存" }] }, + ], + }); + act(() => { + editorOptions.current?.onUpdate?.({ editor: editorMock }); + }); + + await waitFor(() => { + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + }); + }); + it("saves a pending draft before manually creating a version", async () => { vi.mocked(apiClient.listVersions).mockResolvedValue([]); renderWithQuery(); diff --git a/frontend/src/features/workspace/SceneEditor.tsx b/frontend/src/features/workspace/SceneEditor.tsx index e3a3c34..625a155 100644 --- a/frontend/src/features/workspace/SceneEditor.tsx +++ b/frontend/src/features/workspace/SceneEditor.tsx @@ -20,7 +20,11 @@ import { apiClient } from "../../api/client"; import { IconButton } from "../../components/IconButton"; import { StableNodeId } from "../../extensions/StableNodeId"; import { StatusPill } from "../../components/StatusPill"; -import type { Scene, SceneVersion } from "../../types/entities"; +import type { + Scene, + SceneVersion, + SceneWorkingDraft, +} from "../../types/entities"; import { label, SCENE_STATUS_LABELS } from "../../utils/enumLabels"; import { RichTextCodec, @@ -37,6 +41,8 @@ interface SceneEditorProps { onVersionCreated?: (version: SceneVersion) => void; targetWordCount?: number; onContentChange?: (content: string) => void; + onDirtyChange?: (dirty: boolean) => void; + appliedWorkingDraft?: SceneWorkingDraft | null; } function selectInitialDocument( @@ -117,6 +123,8 @@ export function SceneEditor({ onVersionCreated, targetWordCount = 1000, onContentChange, + onDirtyChange, + appliedWorkingDraft = null, }: SceneEditorProps) { const queryClient = useQueryClient(); const [content, setContent] = useState(""); @@ -145,6 +153,7 @@ export function SceneEditor({ const contentRef = useRef(""); const initializedSceneRef = useRef(null); const loadedExplicitVersionRef = useRef(""); + const loadedAppliedDraftRef = useRef(""); const [initializedSceneId, setInitializedSceneId] = useState(""); const editor = useEditor({ @@ -185,6 +194,7 @@ export function SceneEditor({ useEffect(() => { if (!scene?.id) { initializedSceneRef.current = null; + loadedAppliedDraftRef.current = ""; setInitializedSceneId(""); return; } @@ -215,6 +225,7 @@ export function SceneEditor({ editor.commands.setContent(document, false); initializedSceneRef.current = scene.id; loadedExplicitVersionRef.current = ""; + loadedAppliedDraftRef.current = ""; setInitializedSceneId(scene.id); } catch (error) { setContent(""); @@ -236,6 +247,51 @@ export function SceneEditor({ workingDraft.isLoading, ]); + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); + + useEffect(() => { + if ( + !editor || + !scene?.id || + !appliedWorkingDraft || + appliedWorkingDraft.scene_id !== scene.id || + initializedSceneId !== scene.id + ) { + return; + } + const key = `${scene.id}:${appliedWorkingDraft.revision}`; + if (loadedAppliedDraftRef.current === key) { + return; + } + try { + const document = + appliedWorkingDraft.content_json as unknown as RichTextNode; + const markdown = RichTextCodec.toMarkdown(document); + setContent(markdown); + onContentChange?.(markdown); + contentRef.current = markdown; + setContentJson(document); + draftRevisionRef.current = appliedWorkingDraft.revision; + setCodecMessage(""); + setDraftMessage(""); + setDirty(false); + setSaveState("draft_saved"); + editor.commands.setContent(document, false); + loadedAppliedDraftRef.current = key; + } catch (error) { + setCodecMessage(codecFailureMessage(error)); + setSaveState("error"); + } + }, [ + appliedWorkingDraft, + editor, + initializedSceneId, + onContentChange, + scene?.id, + ]); + useEffect(() => { if ( !editor || diff --git a/frontend/src/features/workspace/WorkspacePage.tsx b/frontend/src/features/workspace/WorkspacePage.tsx index 71463cc..b30be9f 100644 --- a/frontend/src/features/workspace/WorkspacePage.tsx +++ b/frontend/src/features/workspace/WorkspacePage.tsx @@ -23,10 +23,12 @@ import type { Chapter, Scene, SceneVersion, + SceneWorkingDraft, Volume, } from "../../types/entities"; import { label, PROJECT_STATUS_LABELS } from "../../utils/enumLabels"; import { MemoryCandidatePanel } from "./MemoryCandidatePanel"; +import { ChangeSetReviewPanel } from "./ChangeSetReviewPanel"; import { ReviewIssuePanel } from "./ReviewIssuePanel"; import { SceneCardEditor } from "./SceneCardEditor"; import { SceneApprovalPanel } from "./SceneApprovalPanel"; @@ -143,6 +145,9 @@ export function WorkspacePage() { const [writingPreferences, setWritingPreferences] = useState(loadWritingPreferences); const [editorContent, setEditorContent] = useState(""); + const [editorDirty, setEditorDirty] = useState(false); + const [appliedWorkingDraft, setAppliedWorkingDraft] = + useState(null); const [discussionInstruction, setDiscussionInstruction] = useState(""); useEffect(() => { @@ -245,6 +250,8 @@ export function WorkspacePage() { setSelectedSceneVersionId(""); setPendingSceneVersionId(""); setHasExplicitSceneVersionSelection(false); + setEditorDirty(false); + setAppliedWorkingDraft(null); }, [selectedSceneId]); useEffect(() => { @@ -1244,6 +1251,8 @@ export function WorkspacePage() { onVersionCreated={handleVersionCreated} targetWordCount={project.data?.default_scene_word_count ?? 1000} onContentChange={setEditorContent} + onDirtyChange={setEditorDirty} + appliedWorkingDraft={appliedWorkingDraft} /> @@ -1267,6 +1276,18 @@ export function WorkspacePage() { /> ) : null} + {rightTab === "changes" ? ( + + ) : null} + {rightTab === "review" ? ( <> { expect(screen.getByRole("button", { name: "AI 续写" })).toHaveClass( "text-brand-700", ); - fireEvent.click(screen.getByRole("button", { name: "一致性审查" })); + fireEvent.click(screen.getByRole("button", { name: "改动审阅" })); - expect(onChange).toHaveBeenCalledWith("review"); + expect(onChange).toHaveBeenCalledWith("changes"); }); }); diff --git a/frontend/src/features/workspace/WorkspacePanelTabs.tsx b/frontend/src/features/workspace/WorkspacePanelTabs.tsx index 3ce3539..5861fe7 100644 --- a/frontend/src/features/workspace/WorkspacePanelTabs.tsx +++ b/frontend/src/features/workspace/WorkspacePanelTabs.tsx @@ -1,5 +1,6 @@ export type WorkspacePanelTab = | "ai" + | "changes" | "review" | "memory" | "history" @@ -20,6 +21,7 @@ const TAB_GROUPS: readonly { { label: "检查", tabs: [ + ["changes", "改动审阅"], ["review", "一致性审查"], ["memory", "记忆候选"], ], diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index bf3bf23..4b1c058 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -136,6 +136,55 @@ export interface SceneWorkingDraft { updated_at: string | null; } +export type ChangeOperationStatus = + | "pending" + | "accepted" + | "rejected" + | "conflicted" + | "orphaned"; + +export interface ChangeOperation extends EntityBase { + change_set_id: string; + sequence_no: number; + operation_type: + | "insert_before" + | "insert_after" + | "replace_block" + | "delete_block"; + target_node_id: string | null; + anchor_before_node_id: string | null; + anchor_after_node_id: string | null; + original_json: Record; + proposed_json: Record; + original_hash: string; + status: ChangeOperationStatus; + accepted_draft_revision: number | null; + conflict_reason: string; +} + +export interface ChangeSet extends EntityBase { + scene_id: string; + base_working_revision: number; + base_document_hash: string; + base_version_id: string | null; + purpose: "generation" | "rewrite" | "review_fix" | "restore" | "merge"; + status: + | "pending" + | "partially_accepted" + | "accepted" + | "rejected" + | "conflicted"; + workflow_run_id: string | null; + summary: string; + applied_version_id: string | null; + operations: ChangeOperation[]; +} + +export interface ChangeSetApplyResult { + change_set: ChangeSet; + draft: SceneWorkingDraft | null; +} + export interface WorkflowRun extends EntityBase { scene_id: string; model_profile_id: string | null;