diff --git a/AGENTS.md b/AGENTS.md index 8aad9758..6b3747bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,12 +4,14 @@ ### 1.1 AI相关 - 当涉及 AI 架构、主流程或 human-only runtime 时,必须阅读 `doc/ai-design/architecture.md` - 当涉及 AI 实现、运行入口、跨层边界或工作区 KB 范围时,必须阅读 `doc/ai-design/implementation-spec.md` 与 `doc/ai-design/data-definition-and-flow.md` +- 当涉及 `mouth` 的 `reference_scope / runtime_artifacts` 组织方式,或 `FileReview` 运行态收口时,必须阅读 `doc/ai-design/implementation-spec.md` 与 `doc/ai-design/data-definition-and-flow.md` - 当涉及 human 的 skill 分类、builtin/external 边界或 skill 基线时,必须阅读 `doc/ai-design/human-skill-design.md` - 当涉及 prompt 结构、工具结果渲染、工具 schema/输出 shape 自动导出或 human AI-facing 文案时,必须阅读 `doc/ai-design/prompt-design.md` ### 1.2 docx相关 - 当涉及 docx 编辑架构、source baseline、replay session 或三视图主线时,必须阅读 `doc/docx-design/architecture.md` - 当涉及 docx 实现约束、review/commit 链路、serializer 或样式继承时,必须阅读 `doc/docx-design/implementation-spec.md` +- 当涉及单文件 `FileReview`、docx 审阅稿生成或提交尝试记录时,必须阅读 `doc/docx-design/implementation-spec.md` 与 `doc/database.md` - 当涉及 docx 数据结构、revision/replay node、artifact 关系或状态流转时,必须阅读 `doc/docx-design/data-definition-and-flow.md` ### 1.3 项目整体相关 diff --git a/doc/ai-design/data-definition-and-flow.md b/doc/ai-design/data-definition-and-flow.md index a5228ecd..7deed432 100644 --- a/doc/ai-design/data-definition-and-flow.md +++ b/doc/ai-design/data-definition-and-flow.md @@ -142,7 +142,8 @@ interface AiRunContext { beginDocxReviewRun: () => void // human task 完成后汇总本次 run 的 docx 改动并生成对应视图 artifact。 - finalizeDocxReviewRun: () => Promise + // 当前返回单文件 FileReviewDetail[];未来其他格式接入时应复用同一外壳。 + finalizeDocxReviewRun: () => Promise // human task 中断或失败后丢弃本次 run 尚未收口的 docx 草稿审阅状态。 discardDocxReviewRun: () => void diff --git a/doc/ai-design/implementation-spec.md b/doc/ai-design/implementation-spec.md index 309c8ffb..f041bd9f 100644 --- a/doc/ai-design/implementation-spec.md +++ b/doc/ai-design/implementation-spec.md @@ -98,9 +98,9 @@ stateDiagram-v2 ### 5.4 `mouth` - 职责:围绕 `task_goal` 把当前已经确定的状态组织成最终用户交付,并回写 `HumanResult`。 -- 主要读取:`task`、`memory.stable`、`memory.history.roundHistory`、`memory.history.loadedHistoryContext`、`execution.artifacts`。 +- 主要读取:`task`、`reference_scope`、`memory.stable`、`memory.history.roundHistory`、`memory.history.loadedHistoryContext`、`execution.runtimeArtifacts`。 - 主要回写:`result`、`execution.status`、`execution.stage`、`trace.steps`。 -- 约束:模型输出必须通过 `ctx.chatText`;输出面向最终用户,优先直接完成用户原始诉求;优先依据 `memory.stable` 中的任务笔记组织交付,当笔记过于概括时再结合统一 `history` 与 `artifacts` 补足细节;可使用自由段落或列表组织,不要求固定报告模板;若开启 `emitAnswerEvents`,增量文本通过 `answer_token` 发出,完成后通过 `answer_completed` 收口。 +- 约束:模型输出必须通过 `ctx.chatText`;输出面向最终用户,但必须忠实于当前整体状态;`mouth` 优先吸收 `reference_scope`、`memory.stable`、统一 `history` 与 changed-files 视图的 `runtimeArtifacts`,不再直接消费 review 内部结构;可使用自由段落或列表组织,不要求固定报告模板;若开启 `emitAnswerEvents`,增量文本通过 `answer_token` 发出,完成后通过 `answer_completed` 收口。 ## 6. 事件与交付 diff --git a/doc/database.md b/doc/database.md index 0a08fb24..0b290ea9 100644 --- a/doc/database.md +++ b/doc/database.md @@ -21,7 +21,7 @@ - `WebSourceStore`:`web_source`(网页资料来源 identity、抓取状态与来源级元数据) - `WorkspaceStore`:`workspace_source` / `workspace_node`(工作空间、目录树快照、准备状态与节点绑定) - `ChatStore`:`chat_session` / `chat_message`(会话与消息 CRUD) - - `DraftReviewStore`:`draft_review` / `draft_review_document` / `draft_review_change` / `draft_commit_job`(草稿审阅、变更确认与提交结果) + - `FileReviewStore`:`file_review` / `file_review_change` / `commit_attempt`(单文件审阅、变更确认与提交结果) - `LlmConfigStore`:`llm_config`(配置 CRUD 与激活态切换) - **组合根(Facade)**: `src/main/services/storage/StorageService.ts` - 统一持有 `core` 与各 domain store @@ -185,9 +185,9 @@ | sources | Utf8(Nullable) | 引用来源(SearchResult[] 的 JSON 字符串) | | error | Boolean(Nullable) | 是否出错 | -### 表: draft_review(草稿审阅汇总表) +### 表: file_review(单文件审阅表) -存储一次 AI 运行产生的待审阅结果集合。 +存储一次 AI 运行中某个单文件的待审阅结果。当前只正式用于 `docx`,未来 `xlsx / pptx` 可沿用同一外壳。 | 字段名 | 类型 | 描述 | | :-------------------- | :--------------- | :--- | @@ -196,7 +196,16 @@ | run_id | Utf8 | 所属运行 | | status | Utf8 | 审阅状态 | | title | Utf8 | 审阅标题 | -| document_count | Int32 | 文档数 | +| source_document_id | Utf8(Nullable) | 源 document ID | +| source_path | Utf8 | 源文件路径 | +| source_name | Utf8 | 源文件名 | +| format | Utf8 | 文件格式 | +| review_render_kind | Utf8(Nullable) | 预览/审阅渲染类型 | +| latest_draft_snapshot_id | Utf8 | 最新草稿快照 | +| draft_artifact_path | Utf8(Nullable) | 待确认稿路径 | +| working_artifact_path | Utf8(Nullable) | working artifact 路径 | +| committed_artifact_path | Utf8(Nullable) | committed artifact 路径 | +| source_version_json | Utf8 | 源版本信息 JSON | | change_count | Int32 | 变更总数 | | pending_change_count | Int32 | 待决策变更数 | | accepted_change_count | Int32 | 已接受变更数 | @@ -207,31 +216,7 @@ | committed_at | Int64(Nullable)| 最终提交时间 | | last_error | Utf8(Nullable) | 最近错误 | -### 表: draft_review_document(草稿审阅文档表) - -存储审阅中的单个草稿文档及其源文件版本信息。 - -| 字段名 | 类型 | 描述 | -| :---------------------- | :--------------- | :--- | -| id | Utf8 | 草稿文档 ID | -| review_id | Utf8 | 所属审阅 ID | -| source_document_id | Utf8(Nullable) | 源 document ID | -| source_path | Utf8 | 源文件路径 | -| source_name | Utf8 | 源文件名 | -| format | Utf8 | 文档格式 | -| latest_draft_snapshot_id| Utf8 | 最新草稿快照 | -| review_artifact_path | Utf8(Nullable) | 审阅稿产物路径 | -| working_artifact_path | Utf8(Nullable) | working draft 产物路径 | -| committed_artifact_path | Utf8(Nullable) | committed view 产物路径 | -| source_version_json | Utf8 | 源版本信息 JSON | -| total_change_count | Int32 | 变更总数 | -| pending_change_count | Int32 | 待决策变更数 | -| accepted_change_count | Int32 | 已接受变更数 | -| rejected_change_count | Int32 | 已拒绝变更数 | -| created_at | Int64 | 创建时间 | -| updated_at | Int64 | 更新时间 | - -### 表: draft_review_change(草稿审阅变更表) +### 表: file_review_change(单文件审阅变更表) 存储最小审阅单元及其接受/拒绝状态。 @@ -239,7 +224,6 @@ | :------------------ | :--------------- | :--- | | id | Utf8 | 变更 ID | | review_id | Utf8 | 所属审阅 ID | -| document_id | Utf8 | 所属草稿文档 ID | | order_index | Int32 | 排序索引 | | change_kind | Utf8 | 变更类型 | | title | Utf8 | 变更标题 | @@ -247,13 +231,14 @@ | decision | Utf8 | `pending / accepted / rejected` | | before_text | Utf8 | 变更前文本 | | after_text | Utf8 | 变更后文本 | -| locator_json | Utf8 | 定位信息 JSON | -| review_artifact_path | Utf8(Nullable)| 局部审阅产物 | +| locator_json | Utf8(Nullable) | 定位信息 JSON | +| diff_hunks_json | Utf8(Nullable) | 统一 diff 片段 | +| source_step_ref | Utf8(Nullable) | 来源步骤引用 | | metadata_json | Utf8(Nullable) | 附加元数据 | | created_at | Int64 | 创建时间 | | updated_at | Int64 | 更新时间 | -### 表: draft_commit_job(草稿提交任务表) +### 表: commit_attempt(提交尝试表) 存储审阅通过后的最终提交执行结果。 @@ -262,7 +247,6 @@ | id | Utf8 | 提交任务 ID | | review_id | Utf8 | 所属审阅 ID | | status | Utf8 | 提交状态 | -| document_count| Int32 | 文档数量 | | results_json | Utf8 | 提交结果 JSON | | started_at | Int64 | 开始时间 | | completed_at | Int64(Nullable)| 完成时间 | diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d621c2f2..d2442698 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -9,9 +9,9 @@ import { StorageService } from '../services/storage/StorageService' import { ModelService } from '../services/model/ModelService' import { AiRunService } from '../services/ai/AiRunService' import { updateSessionMemoryAfterRun } from '../services/ai/memory' -import { DraftReviewService } from '../services/ai/human/tools/docx/review/DraftReviewService' +import { FileReviewService } from '../services/ai/human/tools/docx/review/FileReviewService' import { - getReviewArtifactPath, + getDraftArtifactPath, getWorkingArtifactPath } from '../services/ai/human/tools/docx/review/viewProjection' import { @@ -44,25 +44,25 @@ import type { AiRunCancelResponse, AiRunApprovalDecisionRequest, AiRunApprovalDecisionResponse, - CommitDraftReviewRequest, - CommitDraftReviewResponse, + CommitFileReviewRequest, + CommitFileReviewResponse, CreateWorkspaceSourceRequest, - DiscardDraftReviewRequest, - DiscardDraftReviewResponse, + DiscardFileReviewRequest, + DiscardFileReviewResponse, DeleteWorkspaceSourceResponse, ListReferenceItemsRequest, - ListDraftReviewsRequest, - ListDraftReviewsResponse, + ListFileReviewsRequest, + ListFileReviewsResponse, ListWorkspaceNodesRequest, - GetDraftReviewRequest, - GetDraftReviewResponse, + GetFileReviewRequest, + GetFileReviewResponse, OpenPathWithApplicationRequest, OpenPathWithApplicationResponse, PickDirectoryResponse, PickApplicationResponse, ReferenceItemListResponse, - ValidateDraftCommitRequest, - ValidateDraftCommitResponse, + ValidateFileReviewCommitRequest, + ValidateFileReviewCommitResponse, ListWebSourcesRequest, RefreshWebSourceRequest, DeleteWebSourceRequest, @@ -265,7 +265,7 @@ export function registerHandlers(services: { const aiRuntimeSettingsStore = storageService.aiRuntimeSettings const webSourceStore = storageService.webSource const workspaceStore = storageService.workspace - const draftReviewService = new DraftReviewService(storageService.draftReview, documentsStore) + const fileReviewService = new FileReviewService(storageService.fileReview, documentsStore) const workspaceService = new WorkspaceSourceService() const webSourceService = new WebSourceService() @@ -373,9 +373,9 @@ export function registerHandlers(services: { }) } - const stageE2EDraftReviewDocument = async (input: { + const stageE2EFileReview = async (input: { runId: string - draftReviewService: DraftReviewService + fileReviewService: FileReviewService sourceFileName: string acceptedReplace: string rejectedReplace: string @@ -397,7 +397,7 @@ export function registerHandlers(services: { throw new Error(openResponse.ok ? 'e2e review open worker mismatch' : openResponse.error) } - input.draftReviewService.registerDocxOpen({ + input.fileReviewService.registerDocxOpen({ runId: input.runId, docId: openResponse.payload.docId, sourcePath @@ -432,7 +432,7 @@ export function registerHandlers(services: { ) } - input.draftReviewService.registerDocxChanges({ + input.fileReviewService.registerDocxChanges({ runId: input.runId, previousDocId: openResponse.payload.docId, nextDocId: applyAcceptedResponse.payload.newDocId, @@ -457,7 +457,7 @@ export function registerHandlers(services: { ) } - input.draftReviewService.registerDocxChanges({ + input.fileReviewService.registerDocxChanges({ runId: input.runId, previousDocId: applyAcceptedResponse.payload.newDocId, nextDocId: applyRejectedResponse.payload.newDocId, @@ -472,9 +472,9 @@ export function registerHandlers(services: { } } - const createE2EDraftReviews = async (input: { + const createE2EFileReviews = async (input: { runId: string - draftReviewService: DraftReviewService + fileReviewService: FileReviewService conflict: boolean multiple: boolean primaryDocument?: { @@ -495,9 +495,9 @@ export function registerHandlers(services: { acceptedReplace: input.multiple ? 'E2E accepted paragraph A' : 'E2E accepted paragraph', rejectedReplace: input.multiple ? 'E2E rejected paragraph A' : 'E2E rejected paragraph' } - const firstDocument = await stageE2EDraftReviewDocument({ + const firstDocument = await stageE2EFileReview({ runId: input.runId, - draftReviewService: input.draftReviewService, + fileReviewService: input.fileReviewService, sourceFileName: primaryDocument.sourceFileName, acceptedReplace: primaryDocument.acceptedReplace, rejectedReplace: primaryDocument.rejectedReplace @@ -510,9 +510,9 @@ export function registerHandlers(services: { acceptedReplace: 'E2E accepted paragraph B', rejectedReplace: 'E2E rejected paragraph B' } - const secondDocument = await stageE2EDraftReviewDocument({ + const secondDocument = await stageE2EFileReview({ runId: input.runId, - draftReviewService: input.draftReviewService, + fileReviewService: input.fileReviewService, sourceFileName: secondaryDocument.sourceFileName, acceptedReplace: secondaryDocument.acceptedReplace, rejectedReplace: secondaryDocument.rejectedReplace @@ -520,7 +520,7 @@ export function registerHandlers(services: { stagedSources.push(secondDocument.sourcePath) } - const details = await input.draftReviewService.finalizeRunReview({ + const details = await input.fileReviewService.finalizeRunReview({ runId: input.runId, workspaceRootPath: firstDocument.workspaceRootPath }) @@ -544,7 +544,7 @@ export function registerHandlers(services: { const emitE2EEditReviewFlow = async (input: { runId: string sendEvent: (event: AiRunEvent) => void - draftReviewService: DraftReviewService + fileReviewService: FileReviewService conflict: boolean multiple: boolean primaryDocument?: { @@ -576,9 +576,9 @@ export function registerHandlers(services: { }) await pauseForE2EFixture() - const details = await createE2EDraftReviews({ + const details = await createE2EFileReviews({ runId: input.runId, - draftReviewService: input.draftReviewService, + fileReviewService: input.fileReviewService, conflict: input.conflict, multiple: input.multiple, primaryDocument: input.primaryDocument, @@ -586,25 +586,22 @@ export function registerHandlers(services: { }) details.forEach((detail) => { - detail.documents.forEach((document) => { - const reviewArtifactPath = getReviewArtifactPath(document) - if (!reviewArtifactPath) return - input.sendEvent({ - type: 'file_artifact', - runId: input.runId, - taskId: input.runId, - artifactId: uuidv4(), - capability: 'docx', - path: reviewArtifactPath, - fileName: path.basename(reviewArtifactPath), - operation: 'created', - artifactRole: 'draft', - reviewId: detail.review.id, - draftDocumentId: document.id, - draftSnapshotId: document.latest_draft_snapshot_id, - summary: `review doc ready: ${document.source_name}`, - createdAt: Date.now() - }) + const draftArtifactPath = getDraftArtifactPath(detail.review) + if (!draftArtifactPath) return + input.sendEvent({ + type: 'file_artifact', + runId: input.runId, + taskId: input.runId, + artifactId: uuidv4(), + capability: 'docx', + path: draftArtifactPath, + fileName: path.basename(draftArtifactPath), + operation: 'created', + artifactRole: 'draft', + reviewId: detail.review.id, + draftSnapshotId: detail.review.latest_draft_snapshot_id, + summary: `review doc ready: ${detail.review.source_name}`, + createdAt: Date.now() }) }) await pauseForE2EFixture() @@ -631,7 +628,7 @@ export function registerHandlers(services: { runId: input.runId, review: { reviewId: detail.review.id, - documentCount: detail.review.document_count, + documentCount: 1, changeCount: detail.review.change_count }, createdAt: Date.now() @@ -640,25 +637,22 @@ export function registerHandlers(services: { await pauseForE2EFixture() details.forEach((detail) => { - detail.documents.forEach((document) => { - const workingArtifactPath = getWorkingArtifactPath(document) - if (!workingArtifactPath) return - input.sendEvent({ - type: 'file_artifact', - runId: input.runId, - taskId: input.runId, - artifactId: uuidv4(), - capability: 'docx', - path: workingArtifactPath, - fileName: path.basename(workingArtifactPath), - operation: 'created', - artifactRole: 'draft', - reviewId: detail.review.id, - draftDocumentId: document.id, - draftSnapshotId: document.latest_draft_snapshot_id, - summary: `draft ready: ${document.source_name}`, - createdAt: Date.now() - }) + const workingArtifactPath = getWorkingArtifactPath(detail.review) + if (!workingArtifactPath) return + input.sendEvent({ + type: 'file_artifact', + runId: input.runId, + taskId: input.runId, + artifactId: uuidv4(), + capability: 'docx', + path: workingArtifactPath, + fileName: path.basename(workingArtifactPath), + operation: 'created', + artifactRole: 'draft', + reviewId: detail.review.id, + draftSnapshotId: detail.review.latest_draft_snapshot_id, + summary: `draft ready: ${detail.review.source_name}`, + createdAt: Date.now() }) }) input.sendEvent({ @@ -744,7 +738,7 @@ export function registerHandlers(services: { policyProfile?: string } sendEvent: (event: AiRunEvent) => void - draftReviewService: DraftReviewService + fileReviewService: FileReviewService }): Promise => { if (!isE2EFixtureModeEnabled()) return false @@ -842,7 +836,7 @@ export function registerHandlers(services: { await emitE2EEditReviewFlow({ runId: input.runId, sendEvent: input.sendEvent, - draftReviewService: input.draftReviewService, + fileReviewService: input.fileReviewService, conflict: false, multiple: false }) @@ -853,7 +847,7 @@ export function registerHandlers(services: { await emitE2EEditReviewFlow({ runId: input.runId, sendEvent: input.sendEvent, - draftReviewService: input.draftReviewService, + fileReviewService: input.fileReviewService, conflict: false, multiple: false, primaryDocument: { @@ -870,7 +864,7 @@ export function registerHandlers(services: { await emitE2EEditReviewFlow({ runId: input.runId, sendEvent: input.sendEvent, - draftReviewService: input.draftReviewService, + fileReviewService: input.fileReviewService, conflict: false, multiple: true }) @@ -881,7 +875,7 @@ export function registerHandlers(services: { await emitE2EEditReviewFlow({ runId: input.runId, sendEvent: input.sendEvent, - draftReviewService: input.draftReviewService, + fileReviewService: input.fileReviewService, conflict: true, multiple: false }) @@ -2108,7 +2102,7 @@ export function registerHandlers(services: { } sendEvent({ type: 'run_started', runId, sessionId, createdAt: Date.now(), input }) - draftReviewService.beginRun({ + fileReviewService.beginRun({ runId, sessionId, inputText: input @@ -2120,7 +2114,7 @@ export function registerHandlers(services: { inputText: input, workspace, sendEvent, - draftReviewService + fileReviewService }) if (handledByE2EFixture) { return @@ -2148,7 +2142,7 @@ export function registerHandlers(services: { modelService }) }, - draftReviewService, + fileReviewService, isCancelled: () => Boolean(activeAiRuns.get(runId)?.cancelled), sendEvent, recordLlmIo: services.recordLlmIo, @@ -2237,13 +2231,13 @@ export function registerHandlers(services: { ) if (result.status === 'cancelled') { - draftReviewService.discardRun(runId) + fileReviewService.discardRun(runId) sendEvent({ type: 'run_cancelled', runId }) return } if (result.status === 'failed') { - draftReviewService.discardRun(runId) + fileReviewService.discardRun(runId) sendEvent({ type: 'run_failed', runId, error: result.error || 'failed' }) return } @@ -2266,7 +2260,7 @@ export function registerHandlers(services: { sendEvent({ type: 'run_completed', runId }) } catch (e: unknown) { - draftReviewService.discardRun(runId) + fileReviewService.discardRun(runId) if (activeAiRuns.get(runId)?.cancelled) { sendEvent({ type: 'run_cancelled', runId }) return @@ -2326,10 +2320,10 @@ export function registerHandlers(services: { ) ipcMain.handle( - 'draft-review-list', - async (_event, payload?: ListDraftReviewsRequest): Promise => { + 'file-review-list', + async (_event, payload?: ListFileReviewsRequest): Promise => { try { - return await draftReviewService.listReviews(payload) + return await fileReviewService.listReviews(payload) } catch (e: unknown) { console.error('[DRAFT-REVIEW-LIST] Error:', e) return { items: [], total: 0 } @@ -2338,10 +2332,10 @@ export function registerHandlers(services: { ) ipcMain.handle( - 'draft-review-get', - async (_event, payload: GetDraftReviewRequest): Promise => { + 'file-review-get', + async (_event, payload: GetFileReviewRequest): Promise => { try { - return await draftReviewService.getReview(payload) + return await fileReviewService.getReview(payload) } catch (e: unknown) { console.error('[DRAFT-REVIEW-GET] Error:', e) return { error: getErrorMessage(e) } @@ -2350,10 +2344,10 @@ export function registerHandlers(services: { ) ipcMain.handle( - 'draft-review-validate', - async (_event, payload: ValidateDraftCommitRequest): Promise => { + 'file-review-validate', + async (_event, payload: ValidateFileReviewCommitRequest): Promise => { try { - return await draftReviewService.validateCommit(payload) + return await fileReviewService.validateCommit(payload) } catch (e: unknown) { console.error('[DRAFT-REVIEW-VALIDATE] Error:', e) return { @@ -2370,10 +2364,10 @@ export function registerHandlers(services: { ) ipcMain.handle( - 'draft-review-commit', - async (_event, payload: CommitDraftReviewRequest): Promise => { + 'file-review-commit', + async (_event, payload: CommitFileReviewRequest): Promise => { try { - return await draftReviewService.commitReview(payload) + return await fileReviewService.commitReview(payload) } catch (e: unknown) { console.error('[DRAFT-REVIEW-COMMIT] Error:', e) return { success: false, error: getErrorMessage(e) } @@ -2382,10 +2376,10 @@ export function registerHandlers(services: { ) ipcMain.handle( - 'draft-review-discard', - async (_event, payload: DiscardDraftReviewRequest): Promise => { + 'file-review-discard', + async (_event, payload: DiscardFileReviewRequest): Promise => { try { - return await draftReviewService.discardReview(payload) + return await fileReviewService.discardReview(payload) } catch (e: unknown) { console.error('[DRAFT-REVIEW-DISCARD] Error:', e) return { success: false, error: getErrorMessage(e) } diff --git a/src/main/services/ai/human/graph/humanGraph.ts b/src/main/services/ai/human/graph/humanGraph.ts index 2ae46f89..577b71fd 100644 --- a/src/main/services/ai/human/graph/humanGraph.ts +++ b/src/main/services/ai/human/graph/humanGraph.ts @@ -1,6 +1,5 @@ import { END, START, StateGraph } from '@langchain/langgraph' import type { AiRunContext, HumanRunDispatchOptions } from '../../types' -import { appendDraftReviewArtifactsAndEmitEvents } from '../../utils/artifacts' import type { HumanState, HumanTaskInput, HumanTaskOutput } from '../types' import { createBrainNode } from '../node/brainNode' import { createLearningNode } from '../node/learningNode' @@ -69,24 +68,12 @@ export const createHumanGraphRunner = (input: { ctx: AiRunContext }) => { recursionLimit: RECURSION_LIMIT })) as HumanState - const draftReviewDetails = await ctx.finalizeDocxReviewRun() - const artifacts = - draftReviewDetails.length > 0 - ? appendDraftReviewArtifactsAndEmitEvents({ - existingArtifacts: output.result?.artifacts, - details: draftReviewDetails, - sendEvent: ctx.sendEvent, - runId: ctx.runId, - taskId: taskInput.taskId - }) - : output.result?.artifacts - return { taskId: taskInput.taskId, status: output.result?.status || 'completed', summary: output.result?.summary || '任务未产出最终结果。', deliveryMarkdown: output.result?.deliveryMarkdown || '', - artifacts + artifacts: output.result?.artifacts } } catch (error) { ctx.discardDocxReviewRun() diff --git a/src/main/services/ai/human/node/mouthNode/index.ts b/src/main/services/ai/human/node/mouthNode/index.ts index 9e1805bb..505c4656 100644 --- a/src/main/services/ai/human/node/mouthNode/index.ts +++ b/src/main/services/ai/human/node/mouthNode/index.ts @@ -3,6 +3,11 @@ import type { HumanRuntimeCache, HumanState } from '../../types' import { buildMouthPrompt } from '../../prompts/mouth' import { completeHumanTask } from '../utils/stateTransitions' import { deriveDeliverySummary } from '../../shared/deliverySummary' +import { + appendFileReviewArtifactsAndEmitEvents, + buildRuntimeArtifactsSummaryFromFileReviewDetails, + mergeArtifactRefs +} from '../../../utils/artifacts' export const createMouthNode = (input: { ctx: AiRunContext @@ -13,10 +18,32 @@ export const createMouthNode = (input: { input.ctx.checkCancelled() input.cache.currentState = state - const prompt = buildMouthPrompt({ state }) + const fileReviewDetails = await input.ctx.finalizeDocxReviewRun() + const runtimeArtifacts = buildRuntimeArtifactsSummaryFromFileReviewDetails(fileReviewDetails) + const artifacts = + fileReviewDetails.length > 0 + ? appendFileReviewArtifactsAndEmitEvents({ + existingArtifacts: state.execution.artifacts, + details: fileReviewDetails, + sendEvent: input.ctx.sendEvent, + runId: input.ctx.runId, + taskId: state.task.taskId + }) + : mergeArtifactRefs(state.execution.artifacts) + const readyState: HumanState = { + ...state, + execution: { + ...state.execution, + artifacts, + runtimeArtifacts + } + } + input.cache.currentState = readyState + + const prompt = buildMouthPrompt({ state: readyState, ctx: input.ctx }) const rawDeliveryMarkdown = await input.ctx.chatText({ caller: 'human.mouth', - taskId: state.task.taskId, + taskId: readyState.task.taskId, system: prompt.system, user: prompt.user, schemaName: 'human_mouth_markdown', @@ -26,7 +53,7 @@ export const createMouthNode = (input: { input.ctx.sendEvent({ type: 'answer_token', runId: input.ctx.runId, - taskId: state.task.taskId, + taskId: readyState.task.taskId, token: text, stage: 'human_mouth' }) @@ -36,16 +63,16 @@ export const createMouthNode = (input: { const deliveryMarkdown = String(rawDeliveryMarkdown || '').trim() || - ['# 处理结果', '', state.execution.currentDecision?.decision.summary || '任务结束'].join('\n') + ['# 处理结果', '', readyState.execution.currentDecision?.decision.summary || '任务结束'].join('\n') const summary = deriveDeliverySummary({ deliveryMarkdown, - state + state: readyState }) return completeHumanTask({ ctx: input.ctx, cache: input.cache, - state, + state: readyState, summary, deliveryMarkdown, emitAnswerEvents: input.emitAnswerEvents diff --git a/src/main/services/ai/human/prompts/mouth.ts b/src/main/services/ai/human/prompts/mouth.ts index bf0dd427..c37190cf 100644 --- a/src/main/services/ai/human/prompts/mouth.ts +++ b/src/main/services/ai/human/prompts/mouth.ts @@ -1,10 +1,12 @@ +import type { AiRunContext } from '../../types' import { getLoadedHistoryContext } from '../memory/roundHistory' import { renderUnifiedHistoryText } from '../shared/historyRenderers' import { renderHumanStableNotes } from '../shared/memoryRenderers' import { renderHumanArtifactLines } from '../shared/runtimeRenderers' +import { renderReferenceScopeText } from '../../prompts/referenceScope' import type { HumanState } from '../types' -export const buildMouthPrompt = (input: { state: HumanState }) => { +export const buildMouthPrompt = (input: { state: HumanState; ctx?: AiRunContext }) => { const system = ` @@ -13,18 +15,19 @@ export const buildMouthPrompt = (input: { state: HumanState }) => { - task_goal:用户的诉求。 + - reference_scope:当前工作区、默认资料范围、显式选择的优先参考资料与当前缩圈状态。 - your_notes:当前持续工作的任务笔记。其中: - 当前理解:当前任务的最新长期理解。 - 关键依据:支撑这些理解或下一步动作的证据、来源与关键结果。 - 检查清单:当前任务板,帮助你理解哪些事项已完成、未完成、受阻或已废弃。 - history:任务过程中真实发生过的决策、执行和结果,可用于补足 your_notes 不够具体的地方。 - - runtime_artifacts:运行中已经产生的真实产物,可按给定路径、文件名、状态和摘要引用。 + - runtime_artifacts:当前这次运行已经形成的文件改动结果摘要,可用于判断实际改动了哪些文件。 - 只输出最终 markdown 正文,不要输出 JSON 或额外说明。 - 回答面向用户,但内容必须忠实于当前整体状态,不能脱离已经成立的信息。 - - 优先吸收 your_notes、history、runtime_artifacts 中已经成立的信息,再组织成自然回复。 + - 优先吸收 reference_scope、your_notes、history、runtime_artifacts 中已经成立的信息,再组织成自然回复。 - 不要遗漏会影响用户理解当前任务状态的关键信息。 - 如果需要回复不在上下文中的内容时,在结尾单独加一行“补充说明:...”明确指出这些点来自模型通用知识而非当前本地资料。 @@ -37,6 +40,17 @@ export const buildMouthPrompt = (input: { state: HumanState }) => { ${input.state.task.userInput} + +${renderReferenceScopeText( + input.ctx?.referenceScope || { + selectedReferences: [], + hasExplicitSelectedReferences: false, + hasDirectDocumentNarrowing: false, + defaultKnowledgeScope: 'unavailable' + } +)} + + ${renderHumanStableNotes(input.state, 'mouth_prompt')} diff --git a/src/main/services/ai/human/schema.ts b/src/main/services/ai/human/schema.ts index 4956f266..55f41482 100644 --- a/src/main/services/ai/human/schema.ts +++ b/src/main/services/ai/human/schema.ts @@ -184,6 +184,20 @@ export const humanDeliveryPayloadSchema = z.object({ export const mouthOutputSchema = humanDeliveryPayloadSchema +export const runtimeChangedFileSchema = z.object({ + file_name: z.string().min(1), + source_path: z.string().min(1), + format: z.literal('docx'), + change_count: z.number().int().min(0), + summary: z.string().min(1), + change_highlights: z.array(z.string().min(1)), + draft_artifact_path: z.string().min(1).optional() +}) + +export const runtimeArtifactsSummarySchema = z.object({ + changed_files: z.array(runtimeChangedFileSchema) +}) + export const humanResultSchema = humanDeliveryPayloadSchema.extend({ status: z.literal('completed'), artifacts: z.array(aiArtifactRefSchema).optional() @@ -202,7 +216,8 @@ export const humanExecutionSchema = z.object({ watchHint: humanWatchHintSchema.optional(), lastToolResults: z.array(toolCallResultSchema).optional(), lastLearningOutcome: humanLearningOutcomeSchema.optional(), - artifacts: z.array(aiArtifactRefSchema) + artifacts: z.array(aiArtifactRefSchema), + runtimeArtifacts: runtimeArtifactsSummarySchema.optional() }) export const humanRoundExecutionSchema = z.discriminatedUnion('kind', [ diff --git a/src/main/services/ai/human/shared/runtimeRenderers.ts b/src/main/services/ai/human/shared/runtimeRenderers.ts index 91bb9085..ac591bb9 100644 --- a/src/main/services/ai/human/shared/runtimeRenderers.ts +++ b/src/main/services/ai/human/shared/runtimeRenderers.ts @@ -4,37 +4,51 @@ import { renderLineList } from '../../utils/render' export type HumanRuntimeRenderMode = 'prompt' | 'fallback_markdown' const getRenderFallback = (mode: HumanRuntimeRenderMode): string => - mode === 'fallback_markdown' ? '无' : 'none' + mode === 'fallback_markdown' ? '无' : '本次运行未形成新的文件改动结果。' const getRenderStyle = (mode: HumanRuntimeRenderMode): 'plain' | 'bullet' => mode === 'fallback_markdown' ? 'bullet' : 'plain' -const renderArtifactLine = ( - artifact: HumanState['execution']['artifacts'][number], +const renderChangedFile = ( + file: NonNullable['changed_files'][number], mode: HumanRuntimeRenderMode ): string => { - if (artifact.kind === 'file') { - const roleText = artifact.artifactRole ? `;role=${artifact.artifactRole}` : '' - if (mode === 'prompt') { - return `- 文件 ${artifact.fileName}:path=${artifact.path};capability=${artifact.capability};operation=${artifact.operation}${roleText};summary=${artifact.summary}` - } + const highlights = + file.change_highlights.length > 0 + ? file.change_highlights.map((item) => ` - ${item}`).join('\n') + : ' - 未提供更细的改动标题' - const roleLabel = artifact.artifactRole ? `/${artifact.artifactRole}` : '' - return `${artifact.fileName} (${artifact.path}) [${artifact.capability}/${artifact.operation}${roleLabel}] - ${artifact.summary}` + if (mode === 'fallback_markdown') { + return `${file.file_name} (${file.source_path}) - ${file.summary}` } - if (mode === 'prompt') { - return `- draft review ${artifact.review.reviewId}:documents=${artifact.review.documentCount};changes=${artifact.review.changeCount};summary=${artifact.summary}` - } - - return `draft review ${artifact.review.reviewId} (${artifact.review.documentCount} docs, ${artifact.review.changeCount} changes) - ${artifact.summary}` + const draftLine = file.draft_artifact_path + ? ` - 待确认稿:${file.draft_artifact_path}` + : undefined + return [ + `- 文件:${file.file_name}`, + ` - 路径:${file.source_path}`, + ` - 格式:${file.format}`, + ` - 改动数:${file.change_count}`, + ` - 摘要:${file.summary}`, + ' - 大致改动:', + highlights, + draftLine + ] + .filter(Boolean) + .join('\n') } export const renderHumanArtifactLines = ( state: HumanState, mode: HumanRuntimeRenderMode ): string => - renderLineList(state.execution.artifacts.map((artifact) => renderArtifactLine(artifact, mode)), { - fallback: getRenderFallback(mode), - style: getRenderStyle(mode) - }) + renderLineList( + (state.execution.runtimeArtifacts?.changed_files || []).map((file) => + renderChangedFile(file, mode) + ), + { + fallback: getRenderFallback(mode), + style: getRenderStyle(mode) + } + ) diff --git a/src/main/services/ai/human/tools/docx/review/DraftReviewCommitService.ts b/src/main/services/ai/human/tools/docx/review/DraftReviewCommitService.ts deleted file mode 100644 index 2ce27d71..00000000 --- a/src/main/services/ai/human/tools/docx/review/DraftReviewCommitService.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { randomUUID, createHash } from 'node:crypto' -import fs from 'node:fs/promises' -import path from 'node:path' -import type { - CommitDraftReviewRequest, - CommitDraftReviewResponse, - DraftCommitJob, - DraftCommitResultItem, - DraftCommitValidationIssue, - DraftReviewDocument, - DraftReviewSummary, - ValidateDraftCommitRequest, - ValidateDraftCommitResponse -} from '../../../../../../../shared/types' -import type { DraftReviewStore } from '../../../../../storage/domains/DraftReviewStore' -import { - collectArtifactPaths, - createArchivedCommittedDetail, - isSourceVersionFresh -} from './utils' -import { getCommittedViewPath, getReviewArtifactPath, getWorkingArtifactPath } from './viewProjection' - -const buildTempOutputPath = (sourcePath: string, suffix: string): string => - path.join(path.dirname(sourcePath), `${path.basename(sourcePath)}.${suffix}.tmp`) - -const fileExists = async (filePath: string): Promise => { - try { - await fs.access(filePath) - return true - } catch { - return false - } -} - -const readFileStats = async ( - filePath: string -): Promise<{ checksum: string; byteSize: number }> => { - const buffer = await fs.readFile(filePath) - return { - checksum: createHash('sha256').update(buffer).digest('hex'), - byteSize: buffer.byteLength - } -} - -export class DraftReviewCommitService { - public constructor(private readonly draftReviewStore: DraftReviewStore) {} - - public async validateCommit( - request: ValidateDraftCommitRequest - ): Promise { - const detail = await this.draftReviewStore.getReviewDetail(request.reviewId) - if (!detail) { - return { - ready: false, - issues: [ - { - code: 'session_not_found', - message: '审阅稿不存在或已被移除。' - } - ] - } - } - - const issues: DraftCommitValidationIssue[] = [] - const status = detail.review.status - if ( - status === 'committing' || - status === 'committed' || - status === 'discarded' || - status === 'superseded' - ) { - issues.push({ - code: 'session_not_ready', - message: '当前审阅稿状态不允许提交。' - }) - } - - const documentIssues = await Promise.all( - detail.documents.map(async (document) => await this.validateDocument(detail.review.id, document)) - ) - documentIssues.forEach((documentIssueList) => { - issues.push(...documentIssueList) - }) - - return { - ready: issues.length === 0, - issues - } - } - - public async commitReview( - request: CommitDraftReviewRequest - ): Promise { - const detail = await this.draftReviewStore.getReviewDetail(request.reviewId) - if (!detail) return { success: false, error: 'review session not found' } - if (!request.overwrite) { - return { success: false, error: 'overwrite confirmation is required' } - } - - const validation = await this.validateCommit({ reviewId: request.reviewId }) - if (!validation.ready) { - return await this.handleValidationFailure({ - request, - review: detail.review, - documentCount: detail.documents.length, - issues: validation.issues - }) - } - - const now = Date.now() - const job: DraftCommitJob = { - id: randomUUID(), - review_id: request.reviewId, - status: 'running', - document_count: detail.documents.length, - results: [], - started_at: now - } - await this.appendCommitJob(request.reviewId, job) - await this.updateReviewSummary({ - ...detail.review, - status: 'committing', - updated_at: now, - last_error: undefined - }) - - const results: DraftCommitResultItem[] = [] - const tempPaths: string[] = [] - - try { - for (const document of detail.documents) { - const committedViewPath = getCommittedViewPath(document) - if (!committedViewPath) { - throw new Error(`missing committed view for ${document.source_name}`) - } - const sourceTempPath = buildTempOutputPath(document.source_path, `commit-${job.id}`) - tempPaths.push(sourceTempPath) - - const committedBuffer = await fs.readFile(committedViewPath) - await fs.writeFile(sourceTempPath, committedBuffer) - await fs.rename(sourceTempPath, document.source_path) - - const stats = await readFileStats(document.source_path) - results.push({ - draft_document_id: document.id, - output_path: document.source_path, - operation: 'updated', - checksum: stats.checksum, - byte_size: stats.byteSize - }) - } - - const completedJob: DraftCommitJob = { - ...job, - status: 'completed', - results, - completed_at: Date.now() - } - const committedAt = completedJob.completed_at || Date.now() - const archivedDetail = createArchivedCommittedDetail({ - detail, - completedJob, - committedAt - }) - await this.draftReviewStore.saveReviewDetail(archivedDetail) - await Promise.all( - [...collectArtifactPaths(detail), ...tempPaths].map(async (artifactPath) => { - try { - await fs.rm(artifactPath, { force: true }) - } catch { - return - } - }) - ) - return { - success: true, - job: completedJob - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const failedJob: DraftCommitJob = { - ...job, - status: 'failed', - results, - completed_at: Date.now(), - error: message - } - await this.replaceCommitJob(request.reviewId, failedJob) - await this.updateReviewSummary({ - ...detail.review, - status: 'failed', - updated_at: Date.now(), - last_error: message - }) - await Promise.all( - tempPaths.map(async (artifactPath) => { - try { - await fs.rm(artifactPath, { force: true }) - } catch { - return - } - }) - ) - return { - success: false, - job: failedJob, - error: message - } - } - } - - private async validateDocument( - _reviewId: string, - document: DraftReviewDocument - ): Promise { - const issues: DraftCommitValidationIssue[] = [] - const reviewPath = getReviewArtifactPath(document) - const workingPath = getWorkingArtifactPath(document) - const committedPath = getCommittedViewPath(document) - - if (!reviewPath) { - issues.push({ - code: 'review_artifact_missing', - message: '审阅稿不存在,无法提交。', - documentId: document.id, - sourcePath: document.source_path - }) - return issues - } - - if (!(await fileExists(reviewPath))) { - issues.push({ - code: 'review_artifact_missing', - message: '审阅稿已丢失,需要重新生成。', - documentId: document.id, - sourcePath: document.source_path - }) - return issues - } - - if (!workingPath || !(await fileExists(workingPath))) { - issues.push({ - code: 'working_artifact_missing', - message: '当前工作稿已丢失,需要重新生成。', - documentId: document.id, - sourcePath: document.source_path - }) - return issues - } - - if (!committedPath || !(await fileExists(committedPath))) { - issues.push({ - code: 'committed_artifact_missing', - message: '最终提交稿已丢失,需要重新生成。', - documentId: document.id, - sourcePath: document.source_path - }) - return issues - } - - const fresh = await isSourceVersionFresh(document.source_path, document.source_version) - if (!fresh) { - issues.push({ - code: 'source_changed', - message: '源文件已发生变化,无法基于旧审阅稿直接替换。', - documentId: document.id, - sourcePath: document.source_path - }) - } - - return issues - } - - private async handleValidationFailure(input: { - request: CommitDraftReviewRequest - review: DraftReviewSummary - documentCount: number - issues: DraftCommitValidationIssue[] - }): Promise { - const hasConflict = input.issues.some((issue) => issue.code === 'source_changed') - const now = Date.now() - const errorMessage = input.issues.map((issue) => issue.message).join(';') - let conflictJob: DraftCommitJob | undefined - - if (hasConflict) { - conflictJob = { - id: randomUUID(), - review_id: input.request.reviewId, - status: 'conflicted', - document_count: input.documentCount, - results: [], - started_at: now, - completed_at: now, - error: errorMessage - } - await this.appendCommitJob(input.request.reviewId, conflictJob) - await this.updateReviewSummary({ - ...input.review, - status: 'conflicted', - updated_at: now, - last_error: errorMessage - }) - } - - return { - success: false, - job: conflictJob, - error: errorMessage - } - } - - private async appendCommitJob(reviewId: string, job: DraftCommitJob): Promise { - const jobs = await this.draftReviewStore.listCommitJobs(reviewId) - await this.draftReviewStore.replaceCommitJobs(reviewId, [...jobs, job]) - } - - private async replaceCommitJob(reviewId: string, nextJob: DraftCommitJob): Promise { - const jobs = await this.draftReviewStore.listCommitJobs(reviewId) - const filtered = jobs.filter((job) => job.id !== nextJob.id) - await this.draftReviewStore.replaceCommitJobs(reviewId, [...filtered, nextJob]) - } - - private async updateReviewSummary(review: DraftReviewSummary): Promise { - await this.draftReviewStore.saveReview(review) - } -} diff --git a/src/main/services/ai/human/tools/docx/review/FileReviewCommitService.ts b/src/main/services/ai/human/tools/docx/review/FileReviewCommitService.ts new file mode 100644 index 00000000..72184b1f --- /dev/null +++ b/src/main/services/ai/human/tools/docx/review/FileReviewCommitService.ts @@ -0,0 +1,289 @@ +import { randomUUID, createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import type { + CommitAttempt, + CommitResultItem, + CommitValidationIssue, + CommitFileReviewRequest, + CommitFileReviewResponse, + FileReview, + ValidateFileReviewCommitRequest, + ValidateFileReviewCommitResponse +} from '../../../../../../../shared/types' +import type { FileReviewStore } from '../../../../../storage/domains/FileReviewStore' +import { + collectArtifactPaths, + createArchivedCommittedDetail, + isSourceVersionFresh +} from './utils' +import { + getCommittedArtifactPath, + getDraftArtifactPath, + getWorkingArtifactPath +} from './viewProjection' + +const buildTempOutputPath = (sourcePath: string, suffix: string): string => + path.join(path.dirname(sourcePath), `${path.basename(sourcePath)}.${suffix}.tmp`) + +const fileExists = async (filePath: string): Promise => { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +const readFileStats = async ( + filePath: string +): Promise<{ checksum: string; byteSize: number }> => { + const buffer = await fs.readFile(filePath) + return { + checksum: createHash('sha256').update(buffer).digest('hex'), + byteSize: buffer.byteLength + } +} + +export class FileReviewCommitService { + public constructor(private readonly fileReviewStore: FileReviewStore) {} + + public async validateCommit( + request: ValidateFileReviewCommitRequest + ): Promise { + const detail = await this.fileReviewStore.getReviewDetail(request.reviewId) + if (!detail) { + return { + ready: false, + issues: [{ code: 'session_not_found', message: '审阅稿不存在或已被移除。' }] + } + } + + const issues: CommitValidationIssue[] = [] + const status = detail.review.status + if ( + status === 'committing' || + status === 'committed' || + status === 'discarded' || + status === 'superseded' + ) { + issues.push({ + code: 'session_not_ready', + message: '当前审阅稿状态不允许提交。' + }) + } + + issues.push(...(await this.validateReview(detail.review))) + return { ready: issues.length === 0, issues } + } + + public async commitReview( + request: CommitFileReviewRequest + ): Promise { + const detail = await this.fileReviewStore.getReviewDetail(request.reviewId) + if (!detail) return { success: false, error: 'review session not found' } + if (!request.overwrite) { + return { success: false, error: 'overwrite confirmation is required' } + } + + const validation = await this.validateCommit({ reviewId: request.reviewId }) + if (!validation.ready) { + return await this.handleValidationFailure({ + request, + review: detail.review, + issues: validation.issues + }) + } + + const now = Date.now() + const attempt: CommitAttempt = { + id: randomUUID(), + review_id: request.reviewId, + status: 'running', + results: [], + started_at: now + } + await this.appendCommitAttempt(request.reviewId, attempt) + await this.updateReviewSummary({ + ...detail.review, + status: 'committing', + updated_at: now, + last_error: undefined + }) + + const tempPaths: string[] = [] + + try { + const committedArtifactPath = getCommittedArtifactPath(detail.review) + if (!committedArtifactPath) { + throw new Error(`missing committed view for ${detail.review.source_name}`) + } + const sourceTempPath = buildTempOutputPath(detail.review.source_path, `commit-${attempt.id}`) + tempPaths.push(sourceTempPath) + + const committedBuffer = await fs.readFile(committedArtifactPath) + await fs.writeFile(sourceTempPath, committedBuffer) + await fs.rename(sourceTempPath, detail.review.source_path) + + const stats = await readFileStats(detail.review.source_path) + const results: CommitResultItem[] = [ + { + review_id: detail.review.id, + output_path: detail.review.source_path, + operation: 'updated', + checksum: stats.checksum, + byte_size: stats.byteSize + } + ] + + const completedAttempt: CommitAttempt = { + ...attempt, + status: 'completed', + results, + completed_at: Date.now() + } + const committedAt = completedAttempt.completed_at || Date.now() + const archivedDetail = createArchivedCommittedDetail({ + detail, + completedAttempt, + committedAt + }) + await this.fileReviewStore.saveReviewDetail(archivedDetail) + await Promise.all( + [...collectArtifactPaths(detail), ...tempPaths].map(async (artifactPath) => { + try { + await fs.rm(artifactPath, { force: true }) + } catch { + return + } + }) + ) + return { success: true, job: completedAttempt } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const failedAttempt: CommitAttempt = { + ...attempt, + status: 'failed', + completed_at: Date.now(), + error: message + } + await this.replaceCommitAttempt(request.reviewId, failedAttempt) + await this.updateReviewSummary({ + ...detail.review, + status: 'failed', + updated_at: Date.now(), + last_error: message + }) + await Promise.all( + tempPaths.map(async (artifactPath) => { + try { + await fs.rm(artifactPath, { force: true }) + } catch { + return + } + }) + ) + return { success: false, job: failedAttempt, error: message } + } + } + + private async validateReview(review: FileReview): Promise { + const issues: CommitValidationIssue[] = [] + const draftPath = getDraftArtifactPath(review) + const workingPath = getWorkingArtifactPath(review) + const committedPath = getCommittedArtifactPath(review) + + if (!draftPath) { + issues.push({ + code: 'review_artifact_missing', + message: '审阅稿不存在,无法提交。', + sourcePath: review.source_path + }) + return issues + } + + if (!(await fileExists(draftPath))) { + issues.push({ + code: 'review_artifact_missing', + message: '审阅稿已丢失,需要重新生成。', + sourcePath: review.source_path + }) + return issues + } + + if (!workingPath || !(await fileExists(workingPath))) { + issues.push({ + code: 'working_artifact_missing', + message: '当前工作稿已丢失,需要重新生成。', + sourcePath: review.source_path + }) + return issues + } + + if (!committedPath || !(await fileExists(committedPath))) { + issues.push({ + code: 'committed_artifact_missing', + message: '最终提交稿已丢失,需要重新生成。', + sourcePath: review.source_path + }) + return issues + } + + const fresh = await isSourceVersionFresh(review.source_path, review.source_version) + if (!fresh) { + issues.push({ + code: 'source_changed', + message: '源文件已发生变化,无法基于旧审阅稿直接替换。', + sourcePath: review.source_path + }) + } + return issues + } + + private async handleValidationFailure(input: { + request: CommitFileReviewRequest + review: FileReview + issues: CommitValidationIssue[] + }): Promise { + const hasConflict = input.issues.some((issue) => issue.code === 'source_changed') + const now = Date.now() + const errorMessage = input.issues.map((issue) => issue.message).join(';') + let conflictAttempt: CommitAttempt | undefined + + if (hasConflict) { + conflictAttempt = { + id: randomUUID(), + review_id: input.request.reviewId, + status: 'conflicted', + results: [], + started_at: now, + completed_at: now, + error: errorMessage + } + await this.appendCommitAttempt(input.request.reviewId, conflictAttempt) + await this.updateReviewSummary({ + ...input.review, + status: 'conflicted', + updated_at: now, + last_error: errorMessage + }) + } + + return { success: false, job: conflictAttempt, error: errorMessage } + } + + private async appendCommitAttempt(reviewId: string, nextAttempt: CommitAttempt): Promise { + const attempts = await this.fileReviewStore.listCommitAttempts(reviewId) + await this.fileReviewStore.replaceCommitAttempts(reviewId, [...attempts, nextAttempt]) + } + + private async replaceCommitAttempt(reviewId: string, nextAttempt: CommitAttempt): Promise { + const attempts = await this.fileReviewStore.listCommitAttempts(reviewId) + const filtered = attempts.filter((attempt) => attempt.id !== nextAttempt.id) + await this.fileReviewStore.replaceCommitAttempts(reviewId, [...filtered, nextAttempt]) + } + + private async updateReviewSummary(review: FileReview): Promise { + await this.fileReviewStore.saveReview(review) + } +} diff --git a/src/main/services/ai/human/tools/docx/review/DraftReviewService.ts b/src/main/services/ai/human/tools/docx/review/FileReviewService.ts similarity index 62% rename from src/main/services/ai/human/tools/docx/review/DraftReviewService.ts rename to src/main/services/ai/human/tools/docx/review/FileReviewService.ts index 414b01e2..8b020bbe 100644 --- a/src/main/services/ai/human/tools/docx/review/DraftReviewService.ts +++ b/src/main/services/ai/human/tools/docx/review/FileReviewService.ts @@ -1,35 +1,40 @@ import fs from 'node:fs/promises' import type { - CommitDraftReviewRequest, - CommitDraftReviewResponse, - DiscardDraftReviewRequest, - DiscardDraftReviewResponse, - DraftReviewDetail, - GetDraftReviewRequest, - GetDraftReviewResponse, - ListDraftReviewsRequest, - ListDraftReviewsResponse, - ValidateDraftCommitRequest, - ValidateDraftCommitResponse + CommitFileReviewRequest, + CommitFileReviewResponse, + DiscardFileReviewRequest, + DiscardFileReviewResponse, + FileReviewDetail, + GetFileReviewRequest, + GetFileReviewResponse, + ListFileReviewsRequest, + ListFileReviewsResponse, + ValidateFileReviewCommitRequest, + ValidateFileReviewCommitResponse } from '../../../../../../../shared/types' import type { DocumentsStore } from '../../../../../storage/domains/DocumentsStore' -import type { DraftReviewStore } from '../../../../../storage/domains/DraftReviewStore' -import { DraftReviewCommitService } from './DraftReviewCommitService' +import type { FileReviewStore } from '../../../../../storage/domains/FileReviewStore' +import { FileReviewCommitService } from './FileReviewCommitService' import { RunDraftTracker } from './RunDraftTracker' import type { ResolvedDocxOpenTarget } from './types' import { collectArtifactPaths } from './utils' import { getWorkingArtifactPath } from './viewProjection' -export class DraftReviewService { +/** + * FileReviewService currently materializes docx reviews only. + * Future editable formats should reuse the same FileReview/CommitAttempt outer + * model while keeping their own tracker, serializer and commit validation logic. + */ +export class FileReviewService { private readonly runDraftTracker: RunDraftTracker - private readonly commitService: DraftReviewCommitService + private readonly commitService: FileReviewCommitService public constructor( - private readonly draftReviewStore: DraftReviewStore, + private readonly fileReviewStore: FileReviewStore, documentsStore: DocumentsStore ) { this.runDraftTracker = new RunDraftTracker(documentsStore) - this.commitService = new DraftReviewCommitService(draftReviewStore) + this.commitService = new FileReviewCommitService(fileReviewStore) } public beginRun(input: { runId: string; sessionId: string; inputText: string }): void { @@ -64,48 +69,45 @@ export class DraftReviewService { public async finalizeRunReview(input: { runId: string workspaceRootPath: string - }): Promise { + }): Promise { const details = await this.runDraftTracker.finalizeRunReview(input) if (details.length === 0) return [] for (const detail of details) { - const sourcePath = detail.documents[0]?.source_path - if (sourcePath) { - await this.supersedeActiveReviews(sourcePath, detail.review.id) - } - await this.draftReviewStore.saveReviewDetail(detail) + await this.supersedeActiveReviews(detail.review.source_path, detail.review.id) + await this.fileReviewStore.saveReviewDetail(detail) } return details } public async listReviews( - request?: ListDraftReviewsRequest - ): Promise { - return await this.draftReviewStore.listReviews(request) + request?: ListFileReviewsRequest + ): Promise { + return await this.fileReviewStore.listReviews(request) } public async getReview( - request: GetDraftReviewRequest - ): Promise { - const review = await this.draftReviewStore.getReviewDetail(request.reviewId) + request: GetFileReviewRequest + ): Promise { + const review = await this.fileReviewStore.getReviewDetail(request.reviewId) return review ? { review } : { error: 'review session not found' } } public async validateCommit( - request: ValidateDraftCommitRequest - ): Promise { + request: ValidateFileReviewCommitRequest + ): Promise { return await this.commitService.validateCommit(request) } public async commitReview( - request: CommitDraftReviewRequest - ): Promise { + request: CommitFileReviewRequest + ): Promise { return await this.commitService.commitReview(request) } public async discardReview( - request: DiscardDraftReviewRequest - ): Promise { - const detail = await this.draftReviewStore.getReviewDetail(request.reviewId) + request: DiscardFileReviewRequest + ): Promise { + const detail = await this.fileReviewStore.getReviewDetail(request.reviewId) if (!detail) return { success: false, error: 'review session not found' } if ( detail.review.status === 'committing' || @@ -119,6 +121,9 @@ export class DraftReviewService { ...detail.review, status: 'discarded' as const, updated_at: Date.now(), + draft_artifact_path: undefined, + working_artifact_path: undefined, + committed_artifact_path: undefined, last_error: undefined } await Promise.all( @@ -130,16 +135,9 @@ export class DraftReviewService { } }) ) - await this.draftReviewStore.saveReviewDetail({ + await this.fileReviewStore.saveReviewDetail({ ...detail, - review: updatedReview, - documents: detail.documents.map((document) => ({ - ...document, - review_artifact_path: undefined, - working_artifact_path: undefined, - committed_artifact_path: undefined, - updated_at: updatedReview.updated_at - })) + review: updatedReview }) return { success: true, @@ -148,7 +146,7 @@ export class DraftReviewService { } public async resolveDocxOpenTarget(sourcePath: string): Promise { - const activeReviews = await this.draftReviewStore.listActiveReviewsBySourcePath(sourcePath, [ + const activeReviews = await this.fileReviewStore.listActiveReviewsBySourcePath(sourcePath, [ 'pending_confirm', 'conflicted' ]) @@ -160,8 +158,7 @@ export class DraftReviewService { } } - const document = activeDetail.documents.find((item) => item.source_path === sourcePath) - const workingArtifactPath = document ? getWorkingArtifactPath(document) : undefined + const workingArtifactPath = getWorkingArtifactPath(activeDetail.review) if (!workingArtifactPath) { throw new Error('active working draft is missing') } @@ -175,13 +172,13 @@ export class DraftReviewService { sourcePath: string, nextReviewId: string ): Promise { - const activeReviews = await this.draftReviewStore.listActiveReviewsBySourcePath(sourcePath, [ + const activeReviews = await this.fileReviewStore.listActiveReviewsBySourcePath(sourcePath, [ 'pending_confirm', 'conflicted' ]) for (const detail of activeReviews) { if (detail.review.id === nextReviewId) continue - await this.draftReviewStore.saveReviewDetail({ + await this.fileReviewStore.saveReviewDetail({ ...detail, review: { ...detail.review, diff --git a/src/main/services/ai/human/tools/docx/review/RunDraftTracker.ts b/src/main/services/ai/human/tools/docx/review/RunDraftTracker.ts index 1aaf302f..b33e8480 100644 --- a/src/main/services/ai/human/tools/docx/review/RunDraftTracker.ts +++ b/src/main/services/ai/human/tools/docx/review/RunDraftTracker.ts @@ -2,10 +2,9 @@ import { randomUUID } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' import type { - DraftReviewChange, - DraftReviewDetail, - DraftReviewDocument, - DraftReviewSummary + FileReview, + FileReviewChange, + FileReviewDetail } from '../../../../../../../shared/types' import type { DocumentsStore } from '../../../../../storage/domains/DocumentsStore' import type { DocxChangedBlock, DocxCreatedBlock, DocxOp, DocxReplaySession } from '../types' @@ -87,7 +86,6 @@ export class RunDraftTracker { const sourcePath = state.sourcePathByDocId.get(input.previousDocId) if (!sourcePath) return - const document = state.documentBySourcePath.get(sourcePath) if (!document) return @@ -100,7 +98,7 @@ export class RunDraftTracker { public async finalizeRunReview(input: { runId: string workspaceRootPath: string - }): Promise { + }): Promise { const state = this.runDrafts.get(input.runId) if (!state) return [] @@ -113,20 +111,19 @@ export class RunDraftTracker { } const client = getDocxWorkerClient() - const details: DraftReviewDetail[] = [] + const details: FileReviewDetail[] = [] - for (const [index, document] of changedDocuments.entries()) { + for (const document of changedDocuments) { const reviewId = randomUUID() const now = Date.now() - const { reviewArtifactPath, workingArtifactPath, committedArtifactPath } = + const { draftArtifactPath, workingArtifactPath, committedArtifactPath } = buildDocxReviewArtifactPaths({ workspaceRootPath: input.workspaceRootPath, reviewId, - documentIndex: index, sourceName: document.sourceName }) - const effectiveSaveResponse = await client.call({ + const saveResponse = await client.call({ runId: input.runId, action: 'materialize_draft', payload: { @@ -134,42 +131,33 @@ export class RunDraftTracker { outPath: workingArtifactPath } }) - if ( - !effectiveSaveResponse.ok || - effectiveSaveResponse.payload.action !== 'materialize_draft' - ) { + if (!saveResponse.ok || saveResponse.payload.action !== 'materialize_draft') { throw new Error( - effectiveSaveResponse.ok - ? 'draft materialization worker protocol mismatch' - : effectiveSaveResponse.error + saveResponse.ok ? 'draft materialization worker protocol mismatch' : saveResponse.error ) } const sourceDocument = await this.documentsStore.getDocumentByFilePath(document.sourcePath) const sourceVersion = await buildSourceVersion(document.sourcePath) - const documentId = randomUUID() const sourceBuffer = await fs.readFile(document.sourcePath) const latestDraftBuffer = await fs.readFile(workingArtifactPath) const replaySession = document.replaySession const reviewChanges = replaySession ? this.buildReviewChanges({ reviewId, - documentId, session: replaySession, now }) : [] - if (!replaySession || reviewChanges.length === 0) { - continue - } + if (!replaySession || reviewChanges.length === 0) continue await materializeDocxProjectedArtifact({ sourceBuffer, workingBuffer: latestDraftBuffer, session: replaySession, kind: 'review', - outPath: reviewArtifactPath + outPath: draftArtifactPath }) await materializeDocxProjectedArtifact({ sourceBuffer, @@ -178,6 +166,7 @@ export class RunDraftTracker { kind: 'committed', outPath: committedArtifactPath }) + replaySession.viewArtifacts.working = { kind: 'working', path: workingArtifactPath, @@ -185,7 +174,7 @@ export class RunDraftTracker { } replaySession.viewArtifacts.review = { kind: 'review', - path: reviewArtifactPath, + path: draftArtifactPath, createdAt: now } replaySession.viewArtifacts.committed = { @@ -194,33 +183,22 @@ export class RunDraftTracker { createdAt: now } - const reviewDocument: DraftReviewDocument = { - id: documentId, - review_id: reviewId, - source_document_id: sourceDocument?.id, - source_path: document.sourcePath, - source_name: document.sourceName, - format: 'docx', - latest_draft_snapshot_id: document.latestDocId, - review_artifact_path: reviewArtifactPath, - working_artifact_path: workingArtifactPath, - committed_artifact_path: committedArtifactPath, - source_version: sourceVersion, - total_change_count: reviewChanges.length, - pending_change_count: reviewChanges.length, - accepted_change_count: 0, - rejected_change_count: 0, - created_at: now, - updated_at: now - } - - const review: DraftReviewSummary = { + const review: FileReview = { id: reviewId, session_id: state.sessionId, run_id: input.runId, status: 'pending_confirm', title: clipText(`审阅稿:${document.sourceName}`, 80) || '审阅稿', - document_count: 1, + source_document_id: sourceDocument?.id, + source_path: document.sourcePath, + source_name: document.sourceName, + format: 'docx', + review_render_kind: 'docx_block', + latest_draft_snapshot_id: document.latestDocId, + draft_artifact_path: draftArtifactPath, + working_artifact_path: workingArtifactPath, + committed_artifact_path: committedArtifactPath, + source_version: sourceVersion, change_count: reviewChanges.length, pending_change_count: reviewChanges.length, accepted_change_count: 0, @@ -232,9 +210,8 @@ export class RunDraftTracker { details.push({ review, - documents: [reviewDocument], changes: reviewChanges, - commit_jobs: [] + commit_attempts: [] }) } @@ -244,14 +221,15 @@ export class RunDraftTracker { private buildReviewChanges(input: { reviewId: string - documentId: string session: DocxReplaySession now: number - }): DraftReviewChange[] { + }): FileReviewChange[] { const revisions = [...input.session.revisionByDocId.values()] .filter( (revision) => - revision.revisionRef !== 'v1' && (revision.ops?.length || 0) > 0 && (revision.changedBlocks?.length || 0) > 0 + revision.revisionRef !== 'v1' && + (revision.ops?.length || 0) > 0 && + (revision.changedBlocks?.length || 0) > 0 ) .sort((left, right) => left.createdAt - right.createdAt) @@ -259,27 +237,28 @@ export class RunDraftTracker { return revisions.flatMap((revision) => (revision.changedBlocks || []).map((change, changeIndex) => { const op = revision.ops?.[changeIndex] || revision.ops?.[0] - return ({ - id: `${input.reviewId}-${revision.revisionRef}-${String(orderIndex)}`, - review_id: input.reviewId, - document_id: input.documentId, - order_index: orderIndex++, - change_kind: op ? getDocxChangeKind(op) : 'block', - title: op ? buildDocxChangeTitle(op, change) : revision.summary || '文档修改', - summary: op ? buildDocxChangeSummary(op, change) : revision.summary || '', - decision: 'pending' as const, - before_text: change.beforeText, - after_text: change.afterText, - locator: buildDocxChangeLocator(change), - metadata: { - revision_ref: revision.revisionRef, - group_id: revision.revisionRef, - ...(op ? { docx_op_json: JSON.stringify(op) } : {}), - ...(revision.createdBlocks ? { created_blocks_json: JSON.stringify(revision.createdBlocks) } : {}) - }, - created_at: input.now, - updated_at: input.now - }) + return { + id: `${input.reviewId}-${revision.revisionRef}-${String(orderIndex)}`, + review_id: input.reviewId, + order_index: orderIndex++, + change_kind: op ? getDocxChangeKind(op) : 'block', + title: op ? buildDocxChangeTitle(op, change) : revision.summary || '文档修改', + summary: op ? buildDocxChangeSummary(op, change) : revision.summary || '', + decision: 'pending', + before_text: change.beforeText, + after_text: change.afterText, + locator: buildDocxChangeLocator(change), + metadata: { + revision_ref: revision.revisionRef, + group_id: revision.revisionRef, + ...(op ? { docx_op_json: JSON.stringify(op) } : {}), + ...(revision.createdBlocks + ? { created_blocks_json: JSON.stringify(revision.createdBlocks) } + : {}) + }, + created_at: input.now, + updated_at: input.now + } }) ) } diff --git a/src/main/services/ai/human/tools/docx/review/utils.ts b/src/main/services/ai/human/tools/docx/review/utils.ts index 2a981c00..e1f743fd 100644 --- a/src/main/services/ai/human/tools/docx/review/utils.ts +++ b/src/main/services/ai/human/tools/docx/review/utils.ts @@ -1,16 +1,20 @@ import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import type { - DraftCommitJob, - DraftReviewChange, - DraftReviewDetail, - DraftReviewStatus, + CommitAttempt, DraftSourceVersion, + FileReview, + FileReviewChange, + FileReviewStatus, JsonObject } from '../../../../../../../shared/types' import { clipText } from '../../../../utils/text' import type { DocxChangedBlock, DocxOp } from '../types' -import { getCommittedViewPath, getReviewArtifactPath, getWorkingArtifactPath } from './viewProjection' +import { + getCommittedArtifactPath, + getDraftArtifactPath, + getWorkingArtifactPath +} from './viewProjection' export const sanitizeFileName = (value: string): string => { const normalized = String(value || '').trim() @@ -37,7 +41,7 @@ export const describeDocxOp = (op: DocxOp): string => { } } -export const getDocxChangeKind = (op: DocxOp): DraftReviewChange['change_kind'] => +export const getDocxChangeKind = (op: DocxOp): FileReviewChange['change_kind'] => op.type === 'update_table_cell' ? 'table_cell' : 'block' export const buildDocxChangeTitle = (op: DocxOp, change: DocxChangedBlock): string => { @@ -57,8 +61,8 @@ export const buildDocxChangeLocator = (change: DocxChangedBlock): JsonObject => export const toReviewStatus = ( hasPending: boolean, - previous?: DraftReviewStatus -): DraftReviewStatus => { + previous?: FileReviewStatus +): FileReviewStatus => { if (previous === 'committing' || previous === 'committed') return previous if ( previous === 'conflicted' || @@ -111,24 +115,22 @@ export const parseDocxOpFromMetadata = (metadata?: JsonObject): DocxOp | null => export const getMetadataGroupId = (metadata?: JsonObject): string => typeof metadata?.group_id === 'string' ? metadata.group_id : '' -export const collectArtifactPaths = (detail: DraftReviewDetail): string[] => { +export const collectArtifactPaths = (detail: { review: FileReview }): string[] => { const unique = new Set() - detail.documents.forEach((document) => { - const reviewArtifactPath = getReviewArtifactPath(document) - const workingArtifactPath = getWorkingArtifactPath(document) - const committedArtifactPath = getCommittedViewPath(document) - if (reviewArtifactPath) unique.add(reviewArtifactPath) - if (workingArtifactPath) unique.add(workingArtifactPath) - if (committedArtifactPath) unique.add(committedArtifactPath) - }) + const draftArtifactPath = getDraftArtifactPath(detail.review) + const workingArtifactPath = getWorkingArtifactPath(detail.review) + const committedArtifactPath = getCommittedArtifactPath(detail.review) + if (draftArtifactPath) unique.add(draftArtifactPath) + if (workingArtifactPath) unique.add(workingArtifactPath) + if (committedArtifactPath) unique.add(committedArtifactPath) return [...unique] } export const createArchivedCommittedDetail = (input: { - detail: DraftReviewDetail - completedJob: DraftCommitJob + detail: { review: FileReview; changes: FileReviewChange[]; commit_attempts: CommitAttempt[] } + completedAttempt: CommitAttempt committedAt: number -}): DraftReviewDetail => { +}): { review: FileReview; changes: FileReviewChange[]; commit_attempts: CommitAttempt[] } => { const updatedAt = input.committedAt return { review: { @@ -136,22 +138,18 @@ export const createArchivedCommittedDetail = (input: { status: 'committed', committed_at: input.committedAt, updated_at: updatedAt, - last_error: undefined - }, - documents: input.detail.documents.map((document) => ({ - ...document, - review_artifact_path: undefined, + draft_artifact_path: undefined, working_artifact_path: undefined, committed_artifact_path: undefined, - updated_at: updatedAt - })), + last_error: undefined + }, changes: input.detail.changes.map((change) => ({ ...change, updated_at: updatedAt })), - commit_jobs: [ - ...input.detail.commit_jobs.filter((job) => job.id !== input.completedJob.id), - input.completedJob + commit_attempts: [ + ...input.detail.commit_attempts.filter((attempt) => attempt.id !== input.completedAttempt.id), + input.completedAttempt ] } } diff --git a/src/main/services/ai/human/tools/docx/review/viewProjection.ts b/src/main/services/ai/human/tools/docx/review/viewProjection.ts index d95be390..a3aa6d53 100644 --- a/src/main/services/ai/human/tools/docx/review/viewProjection.ts +++ b/src/main/services/ai/human/tools/docx/review/viewProjection.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import type { DraftReviewDocument } from '../../../../../../../shared/types' +import type { FileReview } from '../../../../../../../shared/types' const sanitizeProjectedFileName = (value: string): string => { const normalized = String(value || '').trim() @@ -8,7 +8,7 @@ const sanitizeProjectedFileName = (value: string): string => { } export type DocxReviewArtifactPaths = { - reviewArtifactPath: string + draftArtifactPath: string workingArtifactPath: string committedArtifactPath: string } @@ -16,43 +16,30 @@ export type DocxReviewArtifactPaths = { export const buildDocxReviewArtifactPaths = (input: { workspaceRootPath: string reviewId: string - documentIndex: number sourceName: string }): DocxReviewArtifactPaths => { - const draftDir = path.join( + const reviewDir = path.join( path.resolve(input.workspaceRootPath), '.ncurator', - 'draft-reviews', + 'file-reviews', input.reviewId ) const baseName = sanitizeProjectedFileName(input.sourceName) - const reviewArtifactPath = path.join( - draftDir, - `${String(input.documentIndex + 1).padStart(2, '0')}-review-${baseName}` - ) - const workingArtifactPath = path.join( - draftDir, - `${String(input.documentIndex + 1).padStart(2, '0')}-working-${baseName}` - ) - const committedArtifactPath = path.join( - draftDir, - `${String(input.documentIndex + 1).padStart(2, '0')}-committed-${baseName}` - ) return { - reviewArtifactPath, - workingArtifactPath, - committedArtifactPath + draftArtifactPath: path.join(reviewDir, `draft-${baseName}`), + workingArtifactPath: path.join(reviewDir, `working-${baseName}`), + committedArtifactPath: path.join(reviewDir, `committed-${baseName}`) } } -export const getReviewArtifactPath = ( - document: Pick -): string | undefined => document.review_artifact_path +export const getDraftArtifactPath = ( + review: Pick +): string | undefined => review.draft_artifact_path export const getWorkingArtifactPath = ( - document: Pick -): string | undefined => document.working_artifact_path + review: Pick +): string | undefined => review.working_artifact_path -export const getCommittedViewPath = ( - document: Pick -): string | undefined => document.committed_artifact_path +export const getCommittedArtifactPath = ( + review: Pick +): string | undefined => review.committed_artifact_path diff --git a/src/main/services/ai/human/types.ts b/src/main/services/ai/human/types.ts index 472c365d..afee0dfe 100644 --- a/src/main/services/ai/human/types.ts +++ b/src/main/services/ai/human/types.ts @@ -30,6 +30,8 @@ import { humanTaskInputSchema, humanTaskOutputSchema, humanTraceStepSchema, + runtimeArtifactsSummarySchema, + runtimeChangedFileSchema, humanWatchHintSchema, skillDefinitionBaseSchema, toolCallDiagnosticSchema, @@ -91,6 +93,8 @@ export type HumanTaskInput = z.infer export type HumanTaskOutput = z.infer export type HumanExecution = z.infer export type HumanState = z.infer +export type RuntimeChangedFile = z.infer +export type RuntimeArtifactsSummary = z.infer export type HumanRoundExecution = z.infer export type HumanRoundRecord = z.infer export type HumanLoadedHistoryContext = z.infer diff --git a/src/main/services/ai/runtime.ts b/src/main/services/ai/runtime.ts index c30a4369..1c691a33 100644 --- a/src/main/services/ai/runtime.ts +++ b/src/main/services/ai/runtime.ts @@ -87,7 +87,7 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps): requestApproval: deps.requestApproval, loadHistory, trackDocxOpen: ({ docId, sourcePath, replaySession }) => { - deps.draftReviewService.registerDocxOpen({ + deps.fileReviewService.registerDocxOpen({ runId: payload.runId, docId, sourcePath, @@ -102,7 +102,7 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps): createdBlocks, replaySession }) => { - deps.draftReviewService.registerDocxChanges({ + deps.fileReviewService.registerDocxChanges({ runId: payload.runId, previousDocId, nextDocId, @@ -113,9 +113,9 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps): }) }, resolveDocxOpenTarget: async (sourcePath) => - await deps.draftReviewService.resolveDocxOpenTarget(sourcePath), + await deps.fileReviewService.resolveDocxOpenTarget(sourcePath), beginDocxReviewRun: () => { - deps.draftReviewService.beginRun({ + deps.fileReviewService.beginRun({ runId: payload.runId, sessionId: payload.sessionId, inputText: String(payload.input || '') @@ -123,13 +123,13 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps): }, finalizeDocxReviewRun: async () => { if (!normalizedWorkspace?.rootPath) return [] - return await deps.draftReviewService.finalizeRunReview({ + return await deps.fileReviewService.finalizeRunReview({ runId: payload.runId, workspaceRootPath: normalizedWorkspace.rootPath }) }, discardDocxReviewRun: () => { - deps.draftReviewService.discardRun(payload.runId) + deps.fileReviewService.discardRun(payload.runId) }, documentsStore: deps.documentsStore, embeddingService: deps.embeddingService diff --git a/src/main/services/ai/schema.ts b/src/main/services/ai/schema.ts index 9e4e0fa3..926d7144 100644 --- a/src/main/services/ai/schema.ts +++ b/src/main/services/ai/schema.ts @@ -1,13 +1,13 @@ import { z } from 'zod/v4' import type { AiArtifactRef, - AiDraftReviewArtifactRef, - AiDraftReviewRef, + AiFileReviewArtifactRef, + AiFileReviewRef, AiFileArtifactRef } from '../../../shared/types' import { AI_RUN_STATUSES } from './constants' -export const aiDraftReviewRefSchema: z.ZodType = z.object({ +export const aiFileReviewRefSchema: z.ZodType = z.object({ reviewId: z.string().min(1), documentCount: z.number().int().min(0), changeCount: z.number().int().min(0) @@ -26,15 +26,15 @@ export const aiFileArtifactRefSchema: z.ZodType = z.object({ summary: z.string().min(1) }) -export const aiDraftReviewArtifactRefSchema: z.ZodType = z.object({ +export const aiFileReviewArtifactRefSchema: z.ZodType = z.object({ kind: z.literal('draft_review'), - review: aiDraftReviewRefSchema, + review: aiFileReviewRefSchema, summary: z.string().min(1) }) export const aiArtifactRefSchema: z.ZodType = z.union([ aiFileArtifactRefSchema, - aiDraftReviewArtifactRefSchema + aiFileReviewArtifactRefSchema ]) export const aiRunStatusSchema = z.enum(AI_RUN_STATUSES) diff --git a/src/main/services/ai/types.ts b/src/main/services/ai/types.ts index 40a7b2fd..c2175763 100644 --- a/src/main/services/ai/types.ts +++ b/src/main/services/ai/types.ts @@ -9,7 +9,7 @@ import type { DocxOp, DocxReplaySession } from './human/tools/docx/types' -import type { DraftReviewService } from './human/tools/docx/review/DraftReviewService' +import type { FileReviewService } from './human/tools/docx/review/FileReviewService' import type { ResolvedDocxOpenTarget } from './human/tools/docx/review/types' import type { z } from 'zod/v4' import type { @@ -18,7 +18,7 @@ import type { AiRunEvent, AiRunEventStage, AiRunStatus, - DraftReviewDetail, + FileReviewDetail, SelectedReference } from '../../../shared/types' import type { HumanTaskInput, HumanTaskOutput } from './human/types' @@ -89,7 +89,7 @@ export type AiRunDeps = { documentsStore: DocumentsStore embeddingService: EmbeddingService ensureEmbeddingReady: () => Promise - draftReviewService: DraftReviewService + fileReviewService: FileReviewService isCancelled: () => boolean sendEvent: (event: AiRunEvent) => void recordLlmIo?: (record: AiLlmIoRecord) => void @@ -152,7 +152,7 @@ export type AiRunContext = { }) => void resolveDocxOpenTarget: (sourcePath: string) => Promise beginDocxReviewRun: () => void - finalizeDocxReviewRun: () => Promise + finalizeDocxReviewRun: () => Promise discardDocxReviewRun: () => void documentsStore: DocumentsStore embeddingService: EmbeddingService diff --git a/src/main/services/ai/utils/artifacts.ts b/src/main/services/ai/utils/artifacts.ts index 138e05fb..b6cb7370 100644 --- a/src/main/services/ai/utils/artifacts.ts +++ b/src/main/services/ai/utils/artifacts.ts @@ -2,16 +2,16 @@ import path from 'node:path' import { v4 as uuidv4 } from 'uuid' import type { AiArtifactRef, - AiDraftReviewArtifactRef, AiFileArtifactOperation, AiFileArtifactRef, + AiFileReviewArtifactRef, AiRunEvent, - DraftReviewDetail, + FileReviewDetail, JsonObject, - JsonValue + JsonValue, + RuntimeArtifactsSummary } from '../../../../shared/types' import type { ToolCallResult } from '../human/types' -import { getReviewArtifactPath } from '../human/tools/docx/review/viewProjection' const isJsonObject = (value: JsonValue | undefined): value is JsonObject => { if (!value || typeof value !== 'object' || Array.isArray(value)) return false @@ -41,7 +41,6 @@ const createFileArtifactRef = (input: { summary: string artifactRole?: AiFileArtifactRef['artifactRole'] reviewId?: string - draftDocumentId?: string draftSnapshotId?: string }): AiFileArtifactRef => ({ kind: 'file', @@ -51,7 +50,6 @@ const createFileArtifactRef = (input: { operation: input.operation, artifactRole: input.artifactRole, reviewId: input.reviewId, - draftDocumentId: input.draftDocumentId, draftSnapshotId: input.draftSnapshotId, summary: input.summary }) @@ -89,9 +87,7 @@ export const mergeArtifactRefs = ( groups.forEach((group) => { group?.forEach((artifact) => { const key = getArtifactKey(artifact) - if (merged.has(key)) { - merged.delete(key) - } + if (merged.has(key)) merged.delete(key) merged.set(key, artifact) }) }) @@ -106,49 +102,44 @@ export const extractArtifactRefsFromToolResults = ( ): AiArtifactRef[] => mergeArtifactRefs(...results.map((result) => extractArtifactRefsFromToolResult(result))) -export const buildRunArtifactsFromDraftReviewDetail = ( - detail: DraftReviewDetail -): AiArtifactRef[] => { - const draftReviewArtifact: AiDraftReviewArtifactRef = { +export const buildRunArtifactsFromFileReviewDetail = (detail: FileReviewDetail): AiArtifactRef[] => { + const reviewArtifact: AiFileReviewArtifactRef = { kind: 'draft_review', review: { reviewId: detail.review.id, - documentCount: detail.review.document_count, + documentCount: 1, changeCount: detail.review.change_count }, - summary: `review document ready (${detail.review.document_count} docs, ${detail.review.change_count} changes)` + summary: `review document ready (1 doc, ${detail.review.change_count} changes)` } - const draftArtifacts = detail.documents.flatMap((document) => { - const reviewArtifactPath = getReviewArtifactPath(document) - if (!reviewArtifactPath) return [] - return [ - createFileArtifactRef({ - capability: 'docx', - artifactPath: reviewArtifactPath, - operation: 'created', - artifactRole: 'draft', - reviewId: detail.review.id, - draftDocumentId: document.id, - draftSnapshotId: document.latest_draft_snapshot_id, - summary: `review doc ready: ${document.source_name}` - }) - ] - }) + const draftArtifacts = detail.review.draft_artifact_path + ? [ + createFileArtifactRef({ + capability: 'docx', + artifactPath: detail.review.draft_artifact_path, + operation: 'created', + artifactRole: 'draft', + reviewId: detail.review.id, + draftSnapshotId: detail.review.latest_draft_snapshot_id, + summary: `review doc ready: ${detail.review.source_name}` + }) + ] + : [] - return mergeArtifactRefs([draftReviewArtifact], draftArtifacts) + return mergeArtifactRefs([reviewArtifact], draftArtifacts) } -export const appendDraftReviewArtifactsAndEmitEvents = (input: { +export const appendFileReviewArtifactsAndEmitEvents = (input: { existingArtifacts?: readonly AiArtifactRef[] - details: readonly DraftReviewDetail[] + details: readonly FileReviewDetail[] sendEvent: (event: AiRunEvent) => void runId: string taskId: string }): AiArtifactRef[] => { const nextArtifacts = mergeArtifactRefs( input.existingArtifacts, - ...input.details.map((detail) => buildRunArtifactsFromDraftReviewDetail(detail)) + ...input.details.map((detail) => buildRunArtifactsFromFileReviewDetail(detail)) ) input.details.forEach((detail) => { @@ -157,33 +148,51 @@ export const appendDraftReviewArtifactsAndEmitEvents = (input: { runId: input.runId, review: { reviewId: detail.review.id, - documentCount: detail.review.document_count, + documentCount: 1, changeCount: detail.review.change_count }, createdAt: Date.now() }) - detail.documents.forEach((document) => { - const reviewArtifactPath = getReviewArtifactPath(document) - if (!reviewArtifactPath) return - input.sendEvent({ - type: 'file_artifact', - runId: input.runId, - taskId: input.taskId, - artifactId: uuidv4(), - capability: 'docx', - path: reviewArtifactPath, - fileName: path.basename(reviewArtifactPath), - operation: 'created', - artifactRole: 'draft', - reviewId: detail.review.id, - draftDocumentId: document.id, - draftSnapshotId: document.latest_draft_snapshot_id, - summary: `review doc ready: ${document.source_name}`, - createdAt: Date.now() - }) + if (!detail.review.draft_artifact_path) return + input.sendEvent({ + type: 'file_artifact', + runId: input.runId, + taskId: input.taskId, + artifactId: uuidv4(), + capability: 'docx', + path: detail.review.draft_artifact_path, + fileName: path.basename(detail.review.draft_artifact_path), + operation: 'created', + artifactRole: 'draft', + reviewId: detail.review.id, + draftSnapshotId: detail.review.latest_draft_snapshot_id, + summary: `review doc ready: ${detail.review.source_name}`, + createdAt: Date.now() }) }) return nextArtifacts } + +/** + * RuntimeArtifactsSummary is only for mouth consumption. It intentionally + * exposes user-facing changed-file facts instead of internal artifact wiring. + */ +export const buildRuntimeArtifactsSummaryFromFileReviewDetails = ( + details: readonly FileReviewDetail[] +): RuntimeArtifactsSummary => ({ + changed_files: details.map((detail) => ({ + file_name: detail.review.source_name, + source_path: detail.review.source_path, + format: 'docx', + change_count: detail.review.change_count, + summary: + detail.changes[0]?.summary || + (detail.review.change_count > 0 + ? `已生成 ${detail.review.change_count} 处文档修改` + : '已生成待确认稿'), + change_highlights: detail.changes.map((change) => change.title).filter(Boolean), + draft_artifact_path: detail.review.draft_artifact_path + })) +}) diff --git a/src/main/services/storage/StorageService.ts b/src/main/services/storage/StorageService.ts index ac894534..8fd3eb0d 100644 --- a/src/main/services/storage/StorageService.ts +++ b/src/main/services/storage/StorageService.ts @@ -1,7 +1,7 @@ import { AiRuntimeSettingsStore } from './domains/AiRuntimeSettingsStore' import { LanceDbCore } from './core/LanceDbCore' import { ChatStore } from './domains/ChatStore' -import { DraftReviewStore } from './domains/DraftReviewStore' +import { FileReviewStore } from './domains/FileReviewStore' import { DocumentsStore } from './domains/DocumentsStore' import { LlmConfigStore } from './domains/LlmConfigStore' import { WebSourceStore } from './domains/WebSourceStore' @@ -16,7 +16,7 @@ export class StorageService { public readonly documents: DocumentsStore public readonly webSource: WebSourceStore public readonly chat: ChatStore - public readonly draftReview: DraftReviewStore + public readonly fileReview: FileReviewStore public readonly llm: LlmConfigStore public readonly aiRuntimeSettings: AiRuntimeSettingsStore public readonly workspace: WorkspaceStore @@ -27,7 +27,7 @@ export class StorageService { this.documents = new DocumentsStore(this.core) this.webSource = new WebSourceStore(this.core) this.chat = new ChatStore(this.core) - this.draftReview = new DraftReviewStore(this.core) + this.fileReview = new FileReviewStore(this.core) this.llm = new LlmConfigStore(this.core) this.aiRuntimeSettings = new AiRuntimeSettingsStore(this.core) this.workspace = new WorkspaceStore(this.core) diff --git a/src/main/services/storage/core/LanceDbCore.ts b/src/main/services/storage/core/LanceDbCore.ts index 44f03525..9381bb89 100644 --- a/src/main/services/storage/core/LanceDbCore.ts +++ b/src/main/services/storage/core/LanceDbCore.ts @@ -20,10 +20,9 @@ export const LANCE_TABLES = { CHAT_SESSION: 'chat_session', CHAT_MESSAGE: 'chat_message', CHAT_SESSION_MEMORY: 'chat_session_memory', - DRAFT_REVIEW: 'draft_review', - DRAFT_REVIEW_DOCUMENT: 'draft_review_document', - DRAFT_REVIEW_CHANGE: 'draft_review_change', - DRAFT_COMMIT_JOB: 'draft_commit_job', + FILE_REVIEW: 'file_review', + FILE_REVIEW_CHANGE: 'file_review_change', + COMMIT_ATTEMPT: 'commit_attempt', LLM_CONFIG: 'llm_config', AI_RUNTIME_SETTINGS: 'ai_runtime_settings' } as const @@ -305,14 +304,23 @@ export class LanceDbCore { ]) }, { - name: LANCE_TABLES.DRAFT_REVIEW, + name: LANCE_TABLES.FILE_REVIEW, schema: new arrow.Schema([ new arrow.Field('id', new arrow.Utf8()), new arrow.Field('session_id', new arrow.Utf8()), new arrow.Field('run_id', new arrow.Utf8()), new arrow.Field('status', new arrow.Utf8()), new arrow.Field('title', new arrow.Utf8()), - new arrow.Field('document_count', new arrow.Int32()), + new arrow.Field('source_document_id', new arrow.Utf8(), true), + new arrow.Field('source_path', new arrow.Utf8()), + new arrow.Field('source_name', new arrow.Utf8()), + new arrow.Field('format', new arrow.Utf8()), + new arrow.Field('review_render_kind', new arrow.Utf8(), true), + new arrow.Field('latest_draft_snapshot_id', new arrow.Utf8()), + new arrow.Field('draft_artifact_path', new arrow.Utf8(), true), + new arrow.Field('working_artifact_path', new arrow.Utf8(), true), + new arrow.Field('committed_artifact_path', new arrow.Utf8(), true), + new arrow.Field('source_version_json', new arrow.Utf8()), new arrow.Field('change_count', new arrow.Int32()), new arrow.Field('pending_change_count', new arrow.Int32()), new arrow.Field('accepted_change_count', new arrow.Int32()), @@ -325,54 +333,32 @@ export class LanceDbCore { ]) }, { - name: LANCE_TABLES.DRAFT_REVIEW_DOCUMENT, + name: LANCE_TABLES.FILE_REVIEW_CHANGE, schema: new arrow.Schema([ new arrow.Field('id', new arrow.Utf8()), new arrow.Field('review_id', new arrow.Utf8()), - new arrow.Field('source_document_id', new arrow.Utf8(), true), - new arrow.Field('source_path', new arrow.Utf8()), - new arrow.Field('source_name', new arrow.Utf8()), - new arrow.Field('format', new arrow.Utf8()), - new arrow.Field('latest_draft_snapshot_id', new arrow.Utf8()), - new arrow.Field('review_artifact_path', new arrow.Utf8(), true), - new arrow.Field('working_artifact_path', new arrow.Utf8(), true), - new arrow.Field('committed_artifact_path', new arrow.Utf8(), true), - new arrow.Field('source_version_json', new arrow.Utf8()), - new arrow.Field('total_change_count', new arrow.Int32()), - new arrow.Field('pending_change_count', new arrow.Int32()), - new arrow.Field('accepted_change_count', new arrow.Int32()), - new arrow.Field('rejected_change_count', new arrow.Int32()), - new arrow.Field('created_at', new arrow.Int64()), - new arrow.Field('updated_at', new arrow.Int64()) - ]) - }, - { - name: LANCE_TABLES.DRAFT_REVIEW_CHANGE, - schema: new arrow.Schema([ - new arrow.Field('id', new arrow.Utf8()), - new arrow.Field('review_id', new arrow.Utf8()), - new arrow.Field('document_id', new arrow.Utf8()), new arrow.Field('order_index', new arrow.Int32()), new arrow.Field('change_kind', new arrow.Utf8()), new arrow.Field('title', new arrow.Utf8()), new arrow.Field('summary', new arrow.Utf8()), new arrow.Field('decision', new arrow.Utf8()), - new arrow.Field('before_text', new arrow.Utf8()), - new arrow.Field('after_text', new arrow.Utf8()), - new arrow.Field('locator_json', new arrow.Utf8()), - new arrow.Field('review_artifact_path', new arrow.Utf8(), true), + new arrow.Field('before_text', new arrow.Utf8(), true), + new arrow.Field('after_text', new arrow.Utf8(), true), + new arrow.Field('anchor_name', new arrow.Utf8(), true), + new arrow.Field('locator_json', new arrow.Utf8(), true), + new arrow.Field('diff_hunks_json', new arrow.Utf8(), true), + new arrow.Field('source_step_ref', new arrow.Utf8(), true), new arrow.Field('metadata_json', new arrow.Utf8(), true), new arrow.Field('created_at', new arrow.Int64()), new arrow.Field('updated_at', new arrow.Int64()) ]) }, { - name: LANCE_TABLES.DRAFT_COMMIT_JOB, + name: LANCE_TABLES.COMMIT_ATTEMPT, schema: new arrow.Schema([ new arrow.Field('id', new arrow.Utf8()), new arrow.Field('review_id', new arrow.Utf8()), new arrow.Field('status', new arrow.Utf8()), - new arrow.Field('document_count', new arrow.Int32()), new arrow.Field('results_json', new arrow.Utf8()), new arrow.Field('started_at', new arrow.Int64()), new arrow.Field('completed_at', new arrow.Int64(), true), diff --git a/src/main/services/storage/domains/DraftReviewStore.ts b/src/main/services/storage/domains/DraftReviewStore.ts deleted file mode 100644 index f24eaa71..00000000 --- a/src/main/services/storage/domains/DraftReviewStore.ts +++ /dev/null @@ -1,510 +0,0 @@ -import type { - DraftChangeDecision, - DraftCommitJob, - DraftCommitResultItem, - DraftReviewChange, - DraftReviewDetail, - DraftReviewDocument, - DraftReviewStatus, - DraftReviewSummary, - DraftSourceVersion, - JsonObject, - JsonValue, - ListDraftReviewsRequest, - ListDraftReviewsResponse -} from '../../../types/store' -import { LanceDbCore, LANCE_TABLES } from '../core/LanceDbCore' - -type PrimitiveRowValue = string | number | boolean | null | undefined - -type DraftWriteRow = Record - -type DraftReviewRow = { - id?: PrimitiveRowValue - session_id?: PrimitiveRowValue - run_id?: PrimitiveRowValue - status?: PrimitiveRowValue - title?: PrimitiveRowValue - document_count?: PrimitiveRowValue - change_count?: PrimitiveRowValue - pending_change_count?: PrimitiveRowValue - accepted_change_count?: PrimitiveRowValue - rejected_change_count?: PrimitiveRowValue - created_at?: PrimitiveRowValue - updated_at?: PrimitiveRowValue - completed_at?: PrimitiveRowValue - committed_at?: PrimitiveRowValue - last_error?: PrimitiveRowValue -} - -type DraftReviewDocumentRow = { - id?: PrimitiveRowValue - review_id?: PrimitiveRowValue - source_document_id?: PrimitiveRowValue - source_path?: PrimitiveRowValue - source_name?: PrimitiveRowValue - format?: PrimitiveRowValue - latest_draft_snapshot_id?: PrimitiveRowValue - review_artifact_path?: PrimitiveRowValue - working_artifact_path?: PrimitiveRowValue - committed_artifact_path?: PrimitiveRowValue - source_version_json?: PrimitiveRowValue - total_change_count?: PrimitiveRowValue - pending_change_count?: PrimitiveRowValue - accepted_change_count?: PrimitiveRowValue - rejected_change_count?: PrimitiveRowValue - created_at?: PrimitiveRowValue - updated_at?: PrimitiveRowValue -} - -type DraftReviewChangeRow = { - id?: PrimitiveRowValue - review_id?: PrimitiveRowValue - document_id?: PrimitiveRowValue - order_index?: PrimitiveRowValue - change_kind?: PrimitiveRowValue - title?: PrimitiveRowValue - summary?: PrimitiveRowValue - decision?: PrimitiveRowValue - before_text?: PrimitiveRowValue - after_text?: PrimitiveRowValue - locator_json?: PrimitiveRowValue - review_artifact_path?: PrimitiveRowValue - metadata_json?: PrimitiveRowValue - created_at?: PrimitiveRowValue - updated_at?: PrimitiveRowValue -} - -type DraftCommitJobRow = { - id?: PrimitiveRowValue - review_id?: PrimitiveRowValue - status?: PrimitiveRowValue - document_count?: PrimitiveRowValue - results_json?: PrimitiveRowValue - started_at?: PrimitiveRowValue - completed_at?: PrimitiveRowValue - error?: PrimitiveRowValue -} - -const toOptionalString = (value: PrimitiveRowValue): string | undefined => { - if (value == null) return undefined - const next = String(value).trim() - return next.length > 0 ? next : undefined -} - -const toRequiredString = (value: PrimitiveRowValue): string => String(value ?? '') - -const toOptionalNumber = (value: PrimitiveRowValue): number | undefined => { - if (value == null) return undefined - const next = Number(value) - return Number.isFinite(next) ? next : undefined -} - -const toRequiredNumber = (value: PrimitiveRowValue, fallback = 0): number => { - const next = Number(value) - return Number.isFinite(next) ? next : fallback -} - -const parseJsonValue = (value: PrimitiveRowValue): JsonValue | undefined => { - const raw = toOptionalString(value) - if (!raw) return undefined - try { - return JSON.parse(raw) as JsonValue - } catch (error) { - void error - return undefined - } -} - -const parseJsonObject = (value: PrimitiveRowValue): JsonObject | undefined => { - const parsed = parseJsonValue(value) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined - return parsed -} - -const parseSourceVersion = (value: PrimitiveRowValue): DraftSourceVersion => { - const parsed = parseJsonObject(value) - if (!parsed) return {} - return { - mtime_ms: typeof parsed.mtime_ms === 'number' ? parsed.mtime_ms : undefined, - byte_size: typeof parsed.byte_size === 'number' ? parsed.byte_size : undefined, - checksum: typeof parsed.checksum === 'string' ? parsed.checksum : undefined, - content_hash: typeof parsed.content_hash === 'string' ? parsed.content_hash : undefined - } -} - -const parseCommitResults = (value: PrimitiveRowValue): DraftCommitResultItem[] => { - const parsed = parseJsonValue(value) - if (!Array.isArray(parsed)) return [] - return parsed - .filter( - (item): item is JsonObject => - Boolean(item) && typeof item === 'object' && !Array.isArray(item) - ) - .map((item) => ({ - draft_document_id: - typeof item.draft_document_id === 'string' ? item.draft_document_id : '', - output_path: typeof item.output_path === 'string' ? item.output_path : '', - operation: item.operation === 'updated' ? ('updated' as const) : ('created' as const), - checksum: typeof item.checksum === 'string' ? item.checksum : undefined, - byte_size: typeof item.byte_size === 'number' ? item.byte_size : undefined - })) - .filter((item) => item.draft_document_id && item.output_path) -} - -const normalizeDraftReviewStatus = (value: PrimitiveRowValue): DraftReviewStatus => { - const normalized = String(value ?? '').trim() - switch (normalized) { - case 'pending_confirm': - case 'committing': - case 'committed': - case 'conflicted': - case 'discarded': - case 'superseded': - case 'failed': - return normalized - default: - return 'pending_confirm' - } -} - -const normalizeDraftChangeDecision = (value: PrimitiveRowValue): DraftChangeDecision => { - const normalized = String(value ?? '').trim() - if (normalized === 'accepted' || normalized === 'rejected') return normalized - return 'pending' -} - -const normalizeDraftDocumentFormat = (value: PrimitiveRowValue): DraftReviewDocument['format'] => { - const normalized = String(value ?? '').trim() - switch (normalized) { - case 'pdf': - case 'xlsx': - case 'pptx': - case 'md': - case 'txt': - return normalized - default: - return 'docx' - } -} - -const normalizeDraftChangeKind = (value: PrimitiveRowValue): DraftReviewChange['change_kind'] => { - const normalized = String(value ?? '').trim() - switch (normalized) { - case 'text_hunk': - case 'table_cell': - case 'worksheet_range': - case 'slide_object': - case 'page_region': - return normalized - default: - return 'block' - } -} - -const normalizeDraftCommitStatus = (value: PrimitiveRowValue): DraftCommitJob['status'] => { - const normalized = String(value ?? '').trim() - switch (normalized) { - case 'running': - case 'completed': - case 'failed': - case 'conflicted': - return normalized - default: - return 'pending' - } -} - -const toDraftReviewSummary = (row: DraftReviewRow): DraftReviewSummary => ({ - id: toRequiredString(row.id), - session_id: toRequiredString(row.session_id), - run_id: toRequiredString(row.run_id), - status: normalizeDraftReviewStatus(row.status), - title: toRequiredString(row.title), - document_count: toRequiredNumber(row.document_count), - change_count: toRequiredNumber(row.change_count), - pending_change_count: toRequiredNumber(row.pending_change_count), - accepted_change_count: toRequiredNumber(row.accepted_change_count), - rejected_change_count: toRequiredNumber(row.rejected_change_count), - created_at: toRequiredNumber(row.created_at), - updated_at: toRequiredNumber(row.updated_at), - completed_at: toOptionalNumber(row.completed_at), - committed_at: toOptionalNumber(row.committed_at), - last_error: toOptionalString(row.last_error) -}) - -const toDraftReviewDocument = (row: DraftReviewDocumentRow): DraftReviewDocument => ({ - id: toRequiredString(row.id), - review_id: toRequiredString(row.review_id), - source_document_id: toOptionalString(row.source_document_id), - source_path: toRequiredString(row.source_path), - source_name: toRequiredString(row.source_name), - format: normalizeDraftDocumentFormat(row.format), - latest_draft_snapshot_id: toRequiredString(row.latest_draft_snapshot_id), - review_artifact_path: toOptionalString(row.review_artifact_path), - working_artifact_path: toOptionalString(row.working_artifact_path), - committed_artifact_path: toOptionalString(row.committed_artifact_path), - source_version: parseSourceVersion(row.source_version_json), - total_change_count: toRequiredNumber(row.total_change_count), - pending_change_count: toRequiredNumber(row.pending_change_count), - accepted_change_count: toRequiredNumber(row.accepted_change_count), - rejected_change_count: toRequiredNumber(row.rejected_change_count), - created_at: toRequiredNumber(row.created_at), - updated_at: toRequiredNumber(row.updated_at) -}) - -const toDraftReviewChange = (row: DraftReviewChangeRow): DraftReviewChange => ({ - id: toRequiredString(row.id), - review_id: toRequiredString(row.review_id), - document_id: toRequiredString(row.document_id), - order_index: toRequiredNumber(row.order_index), - change_kind: normalizeDraftChangeKind(row.change_kind), - title: toRequiredString(row.title), - summary: toRequiredString(row.summary), - decision: normalizeDraftChangeDecision(row.decision), - before_text: toRequiredString(row.before_text), - after_text: toRequiredString(row.after_text), - anchor_name: - parseJsonObject(row.metadata_json) && - typeof parseJsonObject(row.metadata_json)?.anchor_name === 'string' - ? String(parseJsonObject(row.metadata_json)?.anchor_name) - : undefined, - locator: parseJsonObject(row.locator_json) || {}, - metadata: parseJsonObject(row.metadata_json), - created_at: toRequiredNumber(row.created_at), - updated_at: toRequiredNumber(row.updated_at) -}) - -const toDraftCommitJob = (row: DraftCommitJobRow): DraftCommitJob => ({ - id: toRequiredString(row.id), - review_id: toRequiredString(row.review_id), - status: normalizeDraftCommitStatus(row.status), - document_count: toRequiredNumber(row.document_count), - results: parseCommitResults(row.results_json), - started_at: toRequiredNumber(row.started_at), - completed_at: toOptionalNumber(row.completed_at), - error: toOptionalString(row.error) -}) - -const omitUndefined = (value: T): Partial => { - const next: Partial = {} - for (const key of Object.keys(value) as Array) { - if (value[key] !== undefined) { - next[key] = value[key] - } - } - return next -} - -const toDraftReviewWriteRow = (review: DraftReviewSummary): DraftWriteRow => ({ - id: review.id, - session_id: review.session_id, - run_id: review.run_id, - status: review.status, - title: review.title, - document_count: review.document_count, - change_count: review.change_count, - pending_change_count: review.pending_change_count, - accepted_change_count: review.accepted_change_count, - rejected_change_count: review.rejected_change_count, - created_at: review.created_at, - updated_at: review.updated_at, - completed_at: review.completed_at, - committed_at: review.committed_at, - last_error: review.last_error -}) - -const toDraftReviewDocumentWriteRow = (document: DraftReviewDocument): DraftWriteRow => ({ - id: document.id, - review_id: document.review_id, - source_document_id: document.source_document_id, - source_path: document.source_path, - source_name: document.source_name, - format: document.format, - latest_draft_snapshot_id: document.latest_draft_snapshot_id, - review_artifact_path: document.review_artifact_path, - working_artifact_path: document.working_artifact_path, - committed_artifact_path: document.committed_artifact_path, - source_version_json: JSON.stringify(document.source_version), - total_change_count: document.total_change_count, - pending_change_count: document.pending_change_count, - accepted_change_count: document.accepted_change_count, - rejected_change_count: document.rejected_change_count, - created_at: document.created_at, - updated_at: document.updated_at -}) - -const toDraftReviewChangeWriteRow = (change: DraftReviewChange): DraftWriteRow => ({ - id: change.id, - review_id: change.review_id, - document_id: change.document_id, - order_index: change.order_index, - change_kind: change.change_kind, - title: change.title, - summary: change.summary, - decision: change.decision, - before_text: change.before_text, - after_text: change.after_text, - locator_json: JSON.stringify(change.locator), - review_artifact_path: undefined, - metadata_json: JSON.stringify({ - ...(change.metadata || {}), - ...(change.anchor_name ? { anchor_name: change.anchor_name } : {}) - }), - created_at: change.created_at, - updated_at: change.updated_at -}) - -const toDraftCommitJobWriteRow = (job: DraftCommitJob): DraftWriteRow => ({ - id: job.id, - review_id: job.review_id, - status: job.status, - document_count: job.document_count, - results_json: JSON.stringify(job.results), - started_at: job.started_at, - completed_at: job.completed_at, - error: job.error -}) - -export class DraftReviewStore { - public constructor(private readonly core: LanceDbCore) {} - - public async saveReview(review: DraftReviewSummary): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW) - await table.delete(`id = '${this.core.escapeSqlString(review.id)}'`) - await table.add([omitUndefined(toDraftReviewWriteRow(review))]) - } - - public async listReviews(filters?: ListDraftReviewsRequest): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW) - const where = this.core.combineWhere([ - filters?.sessionId - ? `session_id = '${this.core.escapeSqlString(filters.sessionId)}'` - : undefined, - filters?.status ? `status = '${this.core.escapeSqlString(filters.status)}'` : undefined - ]) - const rows = (await (where ? table.query().where(where) : table.query()).toArray()) as DraftReviewRow[] - const items = rows.map(toDraftReviewSummary).sort((a, b) => b.updated_at - a.updated_at) - return { - items, - total: items.length - } - } - - public async getReview(reviewId: string): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW) - const rows = (await table - .query() - .where(`id = '${this.core.escapeSqlString(reviewId)}'`) - .limit(1) - .toArray()) as DraftReviewRow[] - const row = rows[0] - return row ? toDraftReviewSummary(row) : null - } - - public async replaceDocuments(reviewId: string, documents: DraftReviewDocument[]): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW_DOCUMENT) - await table.delete(`review_id = '${this.core.escapeSqlString(reviewId)}'`) - if (documents.length === 0) return - await table.add(documents.map((document) => omitUndefined(toDraftReviewDocumentWriteRow(document)))) - } - - public async listDocuments(reviewId: string): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW_DOCUMENT) - const rows = (await table - .query() - .where(`review_id = '${this.core.escapeSqlString(reviewId)}'`) - .toArray()) as DraftReviewDocumentRow[] - return rows.map(toDraftReviewDocument).sort((a, b) => a.created_at - b.created_at) - } - - public async replaceChanges(reviewId: string, changes: DraftReviewChange[]): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW_CHANGE) - await table.delete(`review_id = '${this.core.escapeSqlString(reviewId)}'`) - if (changes.length === 0) return - await table.add(changes.map((change) => omitUndefined(toDraftReviewChangeWriteRow(change)))) - } - - public async listChanges(reviewId: string): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW_CHANGE) - const rows = (await table - .query() - .where(`review_id = '${this.core.escapeSqlString(reviewId)}'`) - .toArray()) as DraftReviewChangeRow[] - return rows.map(toDraftReviewChange).sort((a, b) => a.order_index - b.order_index) - } - - public async replaceCommitJobs(reviewId: string, jobs: DraftCommitJob[]): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_COMMIT_JOB) - await table.delete(`review_id = '${this.core.escapeSqlString(reviewId)}'`) - if (jobs.length === 0) return - await table.add(jobs.map((job) => omitUndefined(toDraftCommitJobWriteRow(job)))) - } - - public async listCommitJobs(reviewId: string): Promise { - const table = await this.core.openTable(LANCE_TABLES.DRAFT_COMMIT_JOB) - const rows = (await table - .query() - .where(`review_id = '${this.core.escapeSqlString(reviewId)}'`) - .toArray()) as DraftCommitJobRow[] - return rows.map(toDraftCommitJob).sort((a, b) => a.started_at - b.started_at) - } - - public async saveReviewDetail(detail: DraftReviewDetail): Promise { - await this.saveReview(detail.review) - await this.replaceDocuments(detail.review.id, detail.documents) - await this.replaceChanges(detail.review.id, detail.changes) - await this.replaceCommitJobs(detail.review.id, detail.commit_jobs) - } - - public async getReviewDetail(reviewId: string): Promise { - const review = await this.getReview(reviewId) - if (!review) return null - const [documents, changes, commitJobs] = await Promise.all([ - this.listDocuments(reviewId), - this.listChanges(reviewId), - this.listCommitJobs(reviewId) - ]) - return { - review, - documents, - changes, - commit_jobs: commitJobs - } - } - - public async deleteReview(reviewId: string): Promise { - const safeReviewId = this.core.escapeSqlString(reviewId) - const [reviewTable, documentTable, changeTable, commitJobTable] = await Promise.all([ - this.core.openTable(LANCE_TABLES.DRAFT_REVIEW), - this.core.openTable(LANCE_TABLES.DRAFT_REVIEW_DOCUMENT), - this.core.openTable(LANCE_TABLES.DRAFT_REVIEW_CHANGE), - this.core.openTable(LANCE_TABLES.DRAFT_COMMIT_JOB) - ]) - await reviewTable.delete(`id = '${safeReviewId}'`) - await documentTable.delete(`review_id = '${safeReviewId}'`) - await changeTable.delete(`review_id = '${safeReviewId}'`) - await commitJobTable.delete(`review_id = '${safeReviewId}'`) - } - - public async listActiveReviewsBySourcePath( - sourcePath: string, - statuses: DraftReviewStatus[] - ): Promise { - if (statuses.length === 0) return [] - const documentTable = await this.core.openTable(LANCE_TABLES.DRAFT_REVIEW_DOCUMENT) - const safeSourcePath = this.core.escapeSqlString(sourcePath) - const documentRows = (await documentTable - .query() - .where(`source_path = '${safeSourcePath}'`) - .toArray()) as DraftReviewDocumentRow[] - const reviewIds = Array.from( - new Set(documentRows.map((row) => toOptionalString(row.review_id)).filter(Boolean)) - ) as string[] - const details = await Promise.all(reviewIds.map((reviewId) => this.getReviewDetail(reviewId))) - return details - .filter((detail): detail is DraftReviewDetail => Boolean(detail)) - .filter((detail) => statuses.includes(detail.review.status)) - .sort((left, right) => right.review.updated_at - left.review.updated_at) - } -} diff --git a/src/main/services/storage/domains/FileReviewStore.ts b/src/main/services/storage/domains/FileReviewStore.ts new file mode 100644 index 00000000..de7d1ce9 --- /dev/null +++ b/src/main/services/storage/domains/FileReviewStore.ts @@ -0,0 +1,444 @@ +import type { + CommitAttempt, + CommitAttemptStatus, + CommitResultItem, + DraftChangeDecision, + DraftSourceVersion, + DraftTextDiffHunk, + FileReview, + FileReviewChange, + FileReviewStatus, + JsonObject, + JsonValue, + ListFileReviewsRequest, + ListFileReviewsResponse, +} from '../../../types/store' +import { LanceDbCore, LANCE_TABLES } from '../core/LanceDbCore' + +type PrimitiveRowValue = string | number | boolean | null | undefined +type WriteRow = Record + +type FileReviewRow = { + id?: PrimitiveRowValue + session_id?: PrimitiveRowValue + run_id?: PrimitiveRowValue + status?: PrimitiveRowValue + title?: PrimitiveRowValue + source_document_id?: PrimitiveRowValue + source_path?: PrimitiveRowValue + source_name?: PrimitiveRowValue + format?: PrimitiveRowValue + review_render_kind?: PrimitiveRowValue + latest_draft_snapshot_id?: PrimitiveRowValue + draft_artifact_path?: PrimitiveRowValue + working_artifact_path?: PrimitiveRowValue + committed_artifact_path?: PrimitiveRowValue + source_version_json?: PrimitiveRowValue + change_count?: PrimitiveRowValue + pending_change_count?: PrimitiveRowValue + accepted_change_count?: PrimitiveRowValue + rejected_change_count?: PrimitiveRowValue + created_at?: PrimitiveRowValue + updated_at?: PrimitiveRowValue + completed_at?: PrimitiveRowValue + committed_at?: PrimitiveRowValue + last_error?: PrimitiveRowValue +} + +type FileReviewChangeRow = { + id?: PrimitiveRowValue + review_id?: PrimitiveRowValue + order_index?: PrimitiveRowValue + change_kind?: PrimitiveRowValue + title?: PrimitiveRowValue + summary?: PrimitiveRowValue + decision?: PrimitiveRowValue + before_text?: PrimitiveRowValue + after_text?: PrimitiveRowValue + anchor_name?: PrimitiveRowValue + locator_json?: PrimitiveRowValue + diff_hunks_json?: PrimitiveRowValue + source_step_ref?: PrimitiveRowValue + metadata_json?: PrimitiveRowValue + created_at?: PrimitiveRowValue + updated_at?: PrimitiveRowValue +} + +type CommitAttemptRow = { + id?: PrimitiveRowValue + review_id?: PrimitiveRowValue + status?: PrimitiveRowValue + results_json?: PrimitiveRowValue + started_at?: PrimitiveRowValue + completed_at?: PrimitiveRowValue + error?: PrimitiveRowValue +} + +const toOptionalString = (value: PrimitiveRowValue): string | undefined => { + if (value == null) return undefined + const next = String(value).trim() + return next.length > 0 ? next : undefined +} + +const toRequiredString = (value: PrimitiveRowValue): string => String(value ?? '') + +const toOptionalNumber = (value: PrimitiveRowValue): number | undefined => { + if (value == null) return undefined + const next = Number(value) + return Number.isFinite(next) ? next : undefined +} + +const toRequiredNumber = (value: PrimitiveRowValue, fallback = 0): number => { + const next = Number(value) + return Number.isFinite(next) ? next : fallback +} + +const parseJsonValue = (value: PrimitiveRowValue): JsonValue | undefined => { + const raw = toOptionalString(value) + if (!raw) return undefined + try { + return JSON.parse(raw) as JsonValue + } catch { + return undefined + } +} + +const parseJsonObject = (value: PrimitiveRowValue): JsonObject | undefined => { + const parsed = parseJsonValue(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + return parsed +} + +const parseSourceVersion = (value: PrimitiveRowValue): DraftSourceVersion => { + const parsed = parseJsonObject(value) + if (!parsed) return {} + return { + mtime_ms: typeof parsed.mtime_ms === 'number' ? parsed.mtime_ms : undefined, + byte_size: typeof parsed.byte_size === 'number' ? parsed.byte_size : undefined, + checksum: typeof parsed.checksum === 'string' ? parsed.checksum : undefined, + content_hash: typeof parsed.content_hash === 'string' ? parsed.content_hash : undefined + } +} + +const parseDiffHunks = (value: PrimitiveRowValue): DraftTextDiffHunk[] | undefined => { + const parsed = parseJsonValue(value) + return Array.isArray(parsed) ? (parsed as DraftTextDiffHunk[]) : undefined +} + +const parseCommitResults = (value: PrimitiveRowValue): CommitResultItem[] => { + const parsed = parseJsonValue(value) + if (!Array.isArray(parsed)) return [] + return parsed + .filter( + (item): item is JsonObject => + Boolean(item) && typeof item === 'object' && !Array.isArray(item) + ) + .map((item) => ({ + review_id: typeof item.review_id === 'string' ? item.review_id : '', + output_path: typeof item.output_path === 'string' ? item.output_path : '', + operation: item.operation === 'updated' ? 'updated' : 'created', + checksum: typeof item.checksum === 'string' ? item.checksum : undefined, + byte_size: typeof item.byte_size === 'number' ? item.byte_size : undefined + })) + .filter((item) => item.review_id && item.output_path) +} + +const normalizeFileReviewStatus = (value: PrimitiveRowValue): FileReviewStatus => { + const normalized = String(value ?? '').trim() + switch (normalized) { + case 'pending_confirm': + case 'committing': + case 'committed': + case 'conflicted': + case 'discarded': + case 'superseded': + case 'failed': + return normalized + default: + return 'pending_confirm' + } +} + +const normalizeChangeDecision = (value: PrimitiveRowValue): DraftChangeDecision => { + const normalized = String(value ?? '').trim() + return normalized === 'accepted' || normalized === 'rejected' ? normalized : 'pending' +} + +const normalizeFormat = (value: PrimitiveRowValue): FileReview['format'] => { + const normalized = String(value ?? '').trim() + switch (normalized) { + case 'pdf': + case 'xlsx': + case 'pptx': + case 'md': + case 'txt': + return normalized + default: + return 'docx' + } +} + +const normalizeChangeKind = (value: PrimitiveRowValue): FileReviewChange['change_kind'] => { + const normalized = String(value ?? '').trim() + switch (normalized) { + case 'text_hunk': + case 'table_cell': + case 'worksheet_range': + case 'slide_object': + case 'page_region': + return normalized + default: + return 'block' + } +} + +const normalizeCommitAttemptStatus = (value: PrimitiveRowValue): CommitAttemptStatus => { + const normalized = String(value ?? '').trim() + switch (normalized) { + case 'running': + case 'completed': + case 'failed': + case 'conflicted': + return normalized + default: + return 'pending' + } +} + +const toFileReview = (row: FileReviewRow): FileReview => ({ + id: toRequiredString(row.id), + session_id: toRequiredString(row.session_id), + run_id: toRequiredString(row.run_id), + status: normalizeFileReviewStatus(row.status), + title: toRequiredString(row.title), + source_document_id: toOptionalString(row.source_document_id), + source_path: toRequiredString(row.source_path), + source_name: toRequiredString(row.source_name), + format: normalizeFormat(row.format), + review_render_kind: + row.review_render_kind === 'text_hunk' || row.review_render_kind === 'preview_only' + ? row.review_render_kind + : row.review_render_kind === 'sheet_range' || row.review_render_kind === 'slide_element' + ? row.review_render_kind + : 'docx_block', + latest_draft_snapshot_id: toRequiredString(row.latest_draft_snapshot_id), + draft_artifact_path: toOptionalString(row.draft_artifact_path), + working_artifact_path: toOptionalString(row.working_artifact_path), + committed_artifact_path: toOptionalString(row.committed_artifact_path), + source_version: parseSourceVersion(row.source_version_json), + change_count: toRequiredNumber(row.change_count), + pending_change_count: toRequiredNumber(row.pending_change_count), + accepted_change_count: toRequiredNumber(row.accepted_change_count), + rejected_change_count: toRequiredNumber(row.rejected_change_count), + created_at: toRequiredNumber(row.created_at), + updated_at: toRequiredNumber(row.updated_at), + completed_at: toOptionalNumber(row.completed_at), + committed_at: toOptionalNumber(row.committed_at), + last_error: toOptionalString(row.last_error) +}) + +const toFileReviewChange = (row: FileReviewChangeRow): FileReviewChange => ({ + id: toRequiredString(row.id), + review_id: toRequiredString(row.review_id), + order_index: toRequiredNumber(row.order_index), + change_kind: normalizeChangeKind(row.change_kind), + title: toRequiredString(row.title), + summary: toRequiredString(row.summary), + decision: normalizeChangeDecision(row.decision), + before_text: toOptionalString(row.before_text), + after_text: toOptionalString(row.after_text), + anchor_name: toOptionalString(row.anchor_name), + locator: parseJsonObject(row.locator_json), + diff_hunks: parseDiffHunks(row.diff_hunks_json), + source_step_ref: toOptionalString(row.source_step_ref), + metadata: parseJsonObject(row.metadata_json), + created_at: toRequiredNumber(row.created_at), + updated_at: toRequiredNumber(row.updated_at) +}) + +const toCommitAttempt = (row: CommitAttemptRow): CommitAttempt => ({ + id: toRequiredString(row.id), + review_id: toRequiredString(row.review_id), + status: normalizeCommitAttemptStatus(row.status), + results: parseCommitResults(row.results_json), + started_at: toRequiredNumber(row.started_at), + completed_at: toOptionalNumber(row.completed_at), + error: toOptionalString(row.error) +}) + +const omitUndefined = (value: T): Partial => { + const next: Partial = {} + for (const key of Object.keys(value) as Array) { + if (value[key] !== undefined) next[key] = value[key] + } + return next +} + +const toFileReviewWriteRow = (review: FileReview): WriteRow => ({ + id: review.id, + session_id: review.session_id, + run_id: review.run_id, + status: review.status, + title: review.title, + source_document_id: review.source_document_id, + source_path: review.source_path, + source_name: review.source_name, + format: review.format, + review_render_kind: review.review_render_kind, + latest_draft_snapshot_id: review.latest_draft_snapshot_id, + draft_artifact_path: review.draft_artifact_path, + working_artifact_path: review.working_artifact_path, + committed_artifact_path: review.committed_artifact_path, + source_version_json: JSON.stringify(review.source_version), + change_count: review.change_count, + pending_change_count: review.pending_change_count, + accepted_change_count: review.accepted_change_count, + rejected_change_count: review.rejected_change_count, + created_at: review.created_at, + updated_at: review.updated_at, + completed_at: review.completed_at, + committed_at: review.committed_at, + last_error: review.last_error +}) + +const toFileReviewChangeWriteRow = (change: FileReviewChange): WriteRow => ({ + id: change.id, + review_id: change.review_id, + order_index: change.order_index, + change_kind: change.change_kind, + title: change.title, + summary: change.summary, + decision: change.decision, + before_text: change.before_text, + after_text: change.after_text, + anchor_name: change.anchor_name, + locator_json: change.locator ? JSON.stringify(change.locator) : undefined, + diff_hunks_json: change.diff_hunks ? JSON.stringify(change.diff_hunks) : undefined, + source_step_ref: change.source_step_ref, + metadata_json: change.metadata ? JSON.stringify(change.metadata) : undefined, + created_at: change.created_at, + updated_at: change.updated_at +}) + +const toCommitAttemptWriteRow = (attempt: CommitAttempt): WriteRow => ({ + id: attempt.id, + review_id: attempt.review_id, + status: attempt.status, + results_json: JSON.stringify(attempt.results), + started_at: attempt.started_at, + completed_at: attempt.completed_at, + error: attempt.error +}) + +export class FileReviewStore { + public constructor(private readonly core: LanceDbCore) {} + + public async saveReview(review: FileReview): Promise { + const table = await this.core.openTable(LANCE_TABLES.FILE_REVIEW) + await table.delete(`id = '${this.core.escapeSqlString(review.id)}'`) + await table.add([omitUndefined(toFileReviewWriteRow(review))]) + } + + public async listReviews(filters?: ListFileReviewsRequest): Promise { + const table = await this.core.openTable(LANCE_TABLES.FILE_REVIEW) + const where = this.core.combineWhere([ + filters?.sessionId ? `session_id = '${this.core.escapeSqlString(filters.sessionId)}'` : undefined, + filters?.status ? `status = '${this.core.escapeSqlString(filters.status)}'` : undefined + ]) + const rows = (await (where ? table.query().where(where) : table.query()).toArray()) as FileReviewRow[] + const items = rows.map(toFileReview).sort((a, b) => b.updated_at - a.updated_at) + return { items, total: items.length } + } + + public async getReview(reviewId: string): Promise { + const table = await this.core.openTable(LANCE_TABLES.FILE_REVIEW) + const rows = (await table + .query() + .where(`id = '${this.core.escapeSqlString(reviewId)}'`) + .limit(1) + .toArray()) as FileReviewRow[] + return rows[0] ? toFileReview(rows[0]) : null + } + + public async replaceChanges(reviewId: string, changes: FileReviewChange[]): Promise { + const table = await this.core.openTable(LANCE_TABLES.FILE_REVIEW_CHANGE) + await table.delete(`review_id = '${this.core.escapeSqlString(reviewId)}'`) + if (changes.length === 0) return + await table.add(changes.map((change) => omitUndefined(toFileReviewChangeWriteRow(change)))) + } + + public async listChanges(reviewId: string): Promise { + const table = await this.core.openTable(LANCE_TABLES.FILE_REVIEW_CHANGE) + const rows = (await table + .query() + .where(`review_id = '${this.core.escapeSqlString(reviewId)}'`) + .toArray()) as FileReviewChangeRow[] + return rows.map(toFileReviewChange).sort((a, b) => a.order_index - b.order_index) + } + + public async replaceCommitAttempts(reviewId: string, attempts: CommitAttempt[]): Promise { + const table = await this.core.openTable(LANCE_TABLES.COMMIT_ATTEMPT) + await table.delete(`review_id = '${this.core.escapeSqlString(reviewId)}'`) + if (attempts.length === 0) return + await table.add(attempts.map((attempt) => omitUndefined(toCommitAttemptWriteRow(attempt)))) + } + + public async listCommitAttempts(reviewId: string): Promise { + const table = await this.core.openTable(LANCE_TABLES.COMMIT_ATTEMPT) + const rows = (await table + .query() + .where(`review_id = '${this.core.escapeSqlString(reviewId)}'`) + .toArray()) as CommitAttemptRow[] + return rows.map(toCommitAttempt).sort((a, b) => a.started_at - b.started_at) + } + + public async saveReviewDetail(detail: { review: FileReview; changes: FileReviewChange[]; commit_attempts: CommitAttempt[] }): Promise { + await this.saveReview(detail.review) + await this.replaceChanges(detail.review.id, detail.changes) + await this.replaceCommitAttempts(detail.review.id, detail.commit_attempts) + } + + public async getReviewDetail(reviewId: string): Promise<{ review: FileReview; changes: FileReviewChange[]; commit_attempts: CommitAttempt[] } | null> { + const review = await this.getReview(reviewId) + if (!review) return null + const [changes, commitAttempts] = await Promise.all([ + this.listChanges(reviewId), + this.listCommitAttempts(reviewId) + ]) + return { + review, + changes, + commit_attempts: commitAttempts + } + } + + public async deleteReview(reviewId: string): Promise { + const safeReviewId = this.core.escapeSqlString(reviewId) + const [reviewTable, changeTable, attemptTable] = await Promise.all([ + this.core.openTable(LANCE_TABLES.FILE_REVIEW), + this.core.openTable(LANCE_TABLES.FILE_REVIEW_CHANGE), + this.core.openTable(LANCE_TABLES.COMMIT_ATTEMPT) + ]) + await reviewTable.delete(`id = '${safeReviewId}'`) + await changeTable.delete(`review_id = '${safeReviewId}'`) + await attemptTable.delete(`review_id = '${safeReviewId}'`) + } + + public async listActiveReviewsBySourcePath( + sourcePath: string, + statuses: FileReviewStatus[] + ): Promise<{ review: FileReview; changes: FileReviewChange[]; commit_attempts: CommitAttempt[] }[]> { + if (statuses.length === 0) return [] + const table = await this.core.openTable(LANCE_TABLES.FILE_REVIEW) + const where = this.core.combineWhere([ + `source_path = '${this.core.escapeSqlString(sourcePath)}'`, + this.core.buildInClause('status', statuses) + ]) + const rows = (await table.query().where(where || '').toArray()) as FileReviewRow[] + const reviewIds = rows.map((row) => toRequiredString(row.id)).filter(Boolean) + const details = await Promise.all(reviewIds.map((reviewId) => this.getReviewDetail(reviewId))) + return details + .filter((detail): detail is { review: FileReview; changes: FileReviewChange[]; commit_attempts: CommitAttempt[] } => Boolean(detail)) + .sort((left, right) => right.review.updated_at - left.review.updated_at) + } +} diff --git a/src/main/types/store.d.ts b/src/main/types/store.d.ts index 82462c8a..a7c2bd43 100644 --- a/src/main/types/store.d.ts +++ b/src/main/types/store.d.ts @@ -45,34 +45,35 @@ import { DraftChangeKind, DraftChangeDecision, DraftChangeResolution, - DraftReviewStatus, + FileReviewStatus, DraftArtifactRole, - DraftCommitJobStatus, - DraftCommitValidationIssueCode, + CommitAttemptStatus, + CommitValidationIssueCode, DraftSourceVersion, - DraftReviewSummary, - DraftReviewDocument, - DraftReviewChange, - DraftCommitResultItem, - DraftCommitJob, - DraftReviewDetail, - ListDraftReviewsRequest, - ListDraftReviewsResponse, - GetDraftReviewRequest, - GetDraftReviewResponse, - DraftCommitValidationIssue, - ValidateDraftCommitRequest, - ValidateDraftCommitResponse, - CommitDraftReviewRequest, - CommitDraftReviewResponse, - DiscardDraftReviewRequest, - DiscardDraftReviewResponse, + FileReview, + FileReviewChange, + CommitResultItem, + CommitAttempt, + FileReviewDetail, + ListFileReviewsRequest, + ListFileReviewsResponse, + GetFileReviewRequest, + GetFileReviewResponse, + CommitValidationIssue, + ValidateFileReviewCommitRequest, + ValidateFileReviewCommitResponse, + CommitFileReviewRequest, + CommitFileReviewResponse, + DiscardFileReviewRequest, + DiscardFileReviewResponse, + RuntimeChangedFile, + RuntimeArtifactsSummary, PickApplicationResponse, OpenPathWithApplicationRequest, OpenPathWithApplicationResponse, AiRuntimeSettings, AiArtifactRef, - AiDraftReviewArtifactRef, + AiFileReviewArtifactRef, AiFileArtifactRef, AiRunEvent, AiRunStatus, @@ -133,34 +134,35 @@ export type { DraftChangeKind, DraftChangeDecision, DraftChangeResolution, - DraftReviewStatus, + FileReviewStatus, DraftArtifactRole, - DraftCommitJobStatus, - DraftCommitValidationIssueCode, + CommitAttemptStatus, + CommitValidationIssueCode, DraftSourceVersion, - DraftReviewSummary, - DraftReviewDocument, - DraftReviewChange, - DraftCommitResultItem, - DraftCommitJob, - DraftReviewDetail, - ListDraftReviewsRequest, - ListDraftReviewsResponse, - GetDraftReviewRequest, - GetDraftReviewResponse, - DraftCommitValidationIssue, - ValidateDraftCommitRequest, - ValidateDraftCommitResponse, - CommitDraftReviewRequest, - CommitDraftReviewResponse, - DiscardDraftReviewRequest, - DiscardDraftReviewResponse, + FileReview, + FileReviewChange, + CommitResultItem, + CommitAttempt, + FileReviewDetail, + ListFileReviewsRequest, + ListFileReviewsResponse, + GetFileReviewRequest, + GetFileReviewResponse, + CommitValidationIssue, + ValidateFileReviewCommitRequest, + ValidateFileReviewCommitResponse, + CommitFileReviewRequest, + CommitFileReviewResponse, + DiscardFileReviewRequest, + DiscardFileReviewResponse, + RuntimeChangedFile, + RuntimeArtifactsSummary, PickApplicationResponse, OpenPathWithApplicationRequest, OpenPathWithApplicationResponse, AiRuntimeSettings, AiArtifactRef, - AiDraftReviewArtifactRef, + AiFileReviewArtifactRef, AiFileArtifactRef, AiRunEvent, AiRunStatus, diff --git a/src/preload/api.ts b/src/preload/api.ts index 40f9d43d..299f4074 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -8,19 +8,19 @@ import type { AiRunStartResponse, ChatMessage, ChatSession, - CommitDraftReviewRequest, - CommitDraftReviewResponse, + CommitFileReviewRequest, + CommitFileReviewResponse, ChunkListResponse, CreateWorkspaceSourceRequest, DeleteWebSourceRequest, DeleteWebSourceResponse, DeleteWorkspaceSourceResponse, DocumentRecord, - GetDraftReviewRequest, - GetDraftReviewResponse, + GetFileReviewRequest, + GetFileReviewResponse, LegacyLocalImportMigrationStatus, - ListDraftReviewsRequest, - ListDraftReviewsResponse, + ListFileReviewsRequest, + ListFileReviewsResponse, ListReferenceItemsRequest, ListWebSourcesRequest, ListWorkspaceNodesRequest, @@ -31,14 +31,14 @@ import type { SearchResponse, SearchResult, SearchSourceFilter, - DiscardDraftReviewRequest, - DiscardDraftReviewResponse, + DiscardFileReviewRequest, + DiscardFileReviewResponse, PickApplicationResponse, OpenPathWithApplicationRequest, OpenPathWithApplicationResponse, ReferenceItemListResponse, - ValidateDraftCommitRequest, - ValidateDraftCommitResponse, + ValidateFileReviewCommitRequest, + ValidateFileReviewCommitResponse, WebSourceListResponse, WebSourceMutationResponse, WebIngestPayload, @@ -161,13 +161,13 @@ export type DesktopApi = { aiRunApprovalDecide: ( payload: AiRunApprovalDecisionRequest ) => Promise - draftReviewList: (payload?: ListDraftReviewsRequest) => Promise - draftReviewGet: (payload: GetDraftReviewRequest) => Promise - draftReviewValidate: (payload: ValidateDraftCommitRequest) => Promise - draftReviewCommit: (payload: CommitDraftReviewRequest) => Promise - draftReviewDiscard: ( - payload: DiscardDraftReviewRequest - ) => Promise + fileReviewList: (payload?: ListFileReviewsRequest) => Promise + fileReviewGet: (payload: GetFileReviewRequest) => Promise + fileReviewValidate: (payload: ValidateFileReviewCommitRequest) => Promise + fileReviewCommit: (payload: CommitFileReviewRequest) => Promise + fileReviewDiscard: ( + payload: DiscardFileReviewRequest + ) => Promise pickApplication: () => Promise openPathWithApplication: ( payload: OpenPathWithApplicationRequest diff --git a/src/preload/index.ts b/src/preload/index.ts index 9ac92756..58fa6033 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -173,19 +173,19 @@ const api: DesktopApi = { emitE2EIpcCall('aiRunApprovalDecide') return ipcRenderer.invoke('ai-run-approval-decide', payload) }, - draftReviewList: (payload) => ipcRenderer.invoke('draft-review-list', payload), - draftReviewGet: (payload) => ipcRenderer.invoke('draft-review-get', payload), - draftReviewValidate: (payload) => { - emitE2EIpcCall('draftReviewValidate') - return ipcRenderer.invoke('draft-review-validate', payload) + fileReviewList: (payload) => ipcRenderer.invoke('file-review-list', payload), + fileReviewGet: (payload) => ipcRenderer.invoke('file-review-get', payload), + fileReviewValidate: (payload) => { + emitE2EIpcCall('fileReviewValidate') + return ipcRenderer.invoke('file-review-validate', payload) }, - draftReviewCommit: (payload) => { - emitE2EIpcCall('draftReviewCommit') - return ipcRenderer.invoke('draft-review-commit', payload) + fileReviewCommit: (payload) => { + emitE2EIpcCall('fileReviewCommit') + return ipcRenderer.invoke('file-review-commit', payload) }, - draftReviewDiscard: (payload) => { - emitE2EIpcCall('draftReviewDiscard') - return ipcRenderer.invoke('draft-review-discard', payload) + fileReviewDiscard: (payload) => { + emitE2EIpcCall('fileReviewDiscard') + return ipcRenderer.invoke('file-review-discard', payload) }, onAiRunEvent: (cb: (event: AiRunEvent) => void) => ipcRenderer.on('ai-run-event', (_event, data) => { diff --git a/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx b/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx index a9252f7d..3a18a2f5 100644 --- a/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx +++ b/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Button, Empty, Modal } from 'antd' import { HiOutlineMagnifyingGlass } from 'react-icons/hi2' -import type { DraftReviewDetail, ValidateDraftCommitResponse } from '../../../../shared/types' +import type { FileReviewDetail, ValidateFileReviewCommitResponse } from '../../../../shared/types' import EmbeddedFileViewer from '../fileRenders/EmbeddedFileViewer' import WorkbenchReviewPanel from './WorkbenchReviewPanel' import type { @@ -13,9 +13,9 @@ import { workbenchSelectors } from '../../testing/selectors' type WorkbenchContentPanelProps = { state: WorkbenchContentPanelState - activeReview: DraftReviewDetail | null - draftReviewLoading: boolean - draftCommitValidation: ValidateDraftCommitResponse | null + activeReview: FileReviewDetail | null + fileReviewLoading: boolean + draftCommitValidation: ValidateFileReviewCommitResponse | null draftCommitSubmitting: boolean draftDiscardSubmitting: boolean modalPreviewFile: WorkbenchFileRef | null @@ -32,7 +32,7 @@ type WorkbenchContentPanelProps = { const WorkbenchContentPanel: React.FC = ({ state, activeReview, - draftReviewLoading, + fileReviewLoading, draftCommitValidation, draftCommitSubmitting, draftDiscardSubmitting, @@ -190,7 +190,7 @@ const WorkbenchContentPanel: React.FC = ({ {state.kind === 'review_workspace' ? ( { +const getEndedStateMessage = (status: FileReviewDetail['review']['status']): string => { if (status === 'committed') { return '本文件修改已写回源文件。' } @@ -23,9 +23,9 @@ const getEndedStateMessage = (status: DraftReviewDetail['review']['status']): st } type WorkbenchReviewPanelProps = { - review: DraftReviewDetail | null + review: FileReviewDetail | null loading: boolean - validation: ValidateDraftCommitResponse | null + validation: ValidateFileReviewCommitResponse | null committing: boolean discarding: boolean tabs: ReviewWorkspaceTab[] @@ -47,13 +47,10 @@ const WorkbenchReviewPanel: React.FC = ({ }) => { const [selectedChangeIndex, setSelectedChangeIndex] = useState(0) - const selectedDocument = review?.documents[0] || null const documentChanges = useMemo(() => { - if (!review || !selectedDocument) return [] - return review.changes - .filter((item) => item.document_id === selectedDocument.id) - .sort((left, right) => left.order_index - right.order_index) - }, [review, selectedDocument]) + if (!review) return [] + return [...review.changes].sort((left, right) => left.order_index - right.order_index) + }, [review]) useEffect(() => { if (documentChanges.length === 0) { @@ -66,18 +63,16 @@ const WorkbenchReviewPanel: React.FC = ({ const selectedChange = documentChanges[selectedChangeIndex] || null const reviewStatusMeta = review ? REVIEW_STATUS_META[review.review.status] : null const isActiveConfirmSession = Boolean( - review && - selectedDocument?.review_artifact_path && - ACTIVE_CONFIRM_STATUSES.has(review.review.status) + review && review.review.draft_artifact_path && ACTIVE_CONFIRM_STATUSES.has(review.review.status) ) - const reviewArtifactPath = selectedDocument?.review_artifact_path - const reviewFileName = selectedDocument - ? `${selectedDocument.source_name.replace(/\.docx$/i, '')}-审阅稿.docx` + const reviewArtifactPath = review?.review.draft_artifact_path + const reviewFileName = review + ? `${review.review.source_name.replace(/\.docx$/i, '')}-审阅稿.docx` : undefined const viewerPath = - isActiveConfirmSession && reviewArtifactPath ? reviewArtifactPath : selectedDocument?.source_path + isActiveConfirmSession && reviewArtifactPath ? reviewArtifactPath : review?.review.source_path const viewerName = - isActiveConfirmSession && reviewFileName ? reviewFileName : selectedDocument?.source_name + isActiveConfirmSession && reviewFileName ? reviewFileName : review?.review.source_name const endedStateMessage = review ? getEndedStateMessage(review.review.status) : '' const commitDisabled = !review || @@ -99,7 +94,7 @@ const WorkbenchReviewPanel: React.FC = ({ ) } - if (!review || !selectedDocument) { + if (!review) { return (
= ({ data-testid={workbenchSelectors.reviewTabs} > {tabs.map((tab) => { - const active = tab.fileKey === selectedDocument.source_path + const active = tab.fileKey === review.review.source_path return (