Refactor AI document workflow

This commit is contained in:
hy committed 2026-04-22 18:21:44 +08:00
1 parent d524c94074
commit c2ef5e32dd
59 files changed
+1656 -1739

No files matched your search

+2
View File
@@ -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 项目整体相关
+2 -1
View File
@@ -142,7 +142,8 @@ interface AiRunContext {
beginDocxReviewRun: () => void
// human task 完成后汇总本次 run 的 docx 改动并生成对应视图 artifact。
finalizeDocxReviewRun: () => Promise<DraftReviewDetail[]>
// 当前返回单文件 FileReviewDetail[];未来其他格式接入时应复用同一外壳。
finalizeDocxReviewRun: () => Promise<FileReviewDetail[]>
// human task 中断或失败后丢弃本次 run 尚未收口的 docx 草稿审阅状态。
discardDocxReviewRun: () => void
+2 -2
View File
@@ -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. 事件与交付
+18 -34
View File
@@ -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)| 完成时间 |
+87 -93
View File
@@ -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<boolean> => {
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<ListDraftReviewsResponse> => {
'file-review-list',
async (_event, payload?: ListFileReviewsRequest): Promise<ListFileReviewsResponse> => {
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<GetDraftReviewResponse> => {
'file-review-get',
async (_event, payload: GetFileReviewRequest): Promise<GetFileReviewResponse> => {
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<ValidateDraftCommitResponse> => {
'file-review-validate',
async (_event, payload: ValidateFileReviewCommitRequest): Promise<ValidateFileReviewCommitResponse> => {
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<CommitDraftReviewResponse> => {
'file-review-commit',
async (_event, payload: CommitFileReviewRequest): Promise<CommitFileReviewResponse> => {
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<DiscardDraftReviewResponse> => {
'file-review-discard',
async (_event, payload: DiscardFileReviewRequest): Promise<DiscardFileReviewResponse> => {
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) }
+1 -14
View File
@@ -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()
@@ -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
+17 -3
View File
@@ -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 = `
<system>
<principles>
@@ -13,18 +15,19 @@ export const buildMouthPrompt = (input: { state: HumanState }) => {
<context_information>
- task_goal:用户的诉求。
- reference_scope:当前工作区、默认资料范围、显式选择的优先参考资料与当前缩圈状态。
- your_notes:当前持续工作的任务笔记。其中:
- 当前理解:当前任务的最新长期理解。
- 关键依据:支撑这些理解或下一步动作的证据、来源与关键结果。
- 检查清单:当前任务板,帮助你理解哪些事项已完成、未完成、受阻或已废弃。
- history:任务过程中真实发生过的决策、执行和结果,可用于补足 your_notes 不够具体的地方。
- runtime_artifacts:运行中已经产生的真实产物,可按给定路径、文件名、状态和摘要引用。
- runtime_artifacts:当前这次运行已经形成的文件改动结果摘要,可用于判断实际改动了哪些文件。
</context_information>
<output_contract>
- 只输出最终 markdown 正文,不要输出 JSON 或额外说明。
- 回答面向用户,但内容必须忠实于当前整体状态,不能脱离已经成立的信息。
- 优先吸收 your_notes、history、runtime_artifacts 中已经成立的信息,再组织成自然回复。
- 优先吸收 reference_scope、your_notes、history、runtime_artifacts 中已经成立的信息,再组织成自然回复。
- 不要遗漏会影响用户理解当前任务状态的关键信息。
- 如果需要回复不在上下文中的内容时,在结尾单独加一行“补充说明:...”明确指出这些点来自模型通用知识而非当前本地资料。
</output_contract>
@@ -37,6 +40,17 @@ export const buildMouthPrompt = (input: { state: HumanState }) => {
${input.state.task.userInput}
</task_goal>
<reference_scope>
${renderReferenceScopeText(
input.ctx?.referenceScope || {
selectedReferences: [],
hasExplicitSelectedReferences: false,
hasDirectDocumentNarrowing: false,
defaultKnowledgeScope: 'unavailable'
}
)}
</reference_scope>
<your_notes>
${renderHumanStableNotes(input.state, 'mouth_prompt')}
</your_notes>
+16 -1
View File
@@ -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', [
@@ -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<HumanState['execution']['runtimeArtifacts']>['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)
}
)
@@ -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<boolean> => {
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<ValidateDraftCommitResponse> {
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<CommitDraftReviewResponse> {
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<DraftCommitValidationIssue[]> {
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<CommitDraftReviewResponse> {
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<void> {
const jobs = await this.draftReviewStore.listCommitJobs(reviewId)
await this.draftReviewStore.replaceCommitJobs(reviewId, [...jobs, job])
}
private async replaceCommitJob(reviewId: string, nextJob: DraftCommitJob): Promise<void> {
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<void> {
await this.draftReviewStore.saveReview(review)
}
}
@@ -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<boolean> => {
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<ValidateFileReviewCommitResponse> {
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<CommitFileReviewResponse> {
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<CommitValidationIssue[]> {
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<CommitFileReviewResponse> {
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<void> {
const attempts = await this.fileReviewStore.listCommitAttempts(reviewId)
await this.fileReviewStore.replaceCommitAttempts(reviewId, [...attempts, nextAttempt])
}
private async replaceCommitAttempt(reviewId: string, nextAttempt: CommitAttempt): Promise<void> {
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<void> {
await this.fileReviewStore.saveReview(review)
}
}
@@ -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<DraftReviewDetail[]> {
}): Promise<FileReviewDetail[]> {
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<ListDraftReviewsResponse> {
return await this.draftReviewStore.listReviews(request)
request?: ListFileReviewsRequest
): Promise<ListFileReviewsResponse> {
return await this.fileReviewStore.listReviews(request)
}
public async getReview(
request: GetDraftReviewRequest
): Promise<GetDraftReviewResponse> {
const review = await this.draftReviewStore.getReviewDetail(request.reviewId)
request: GetFileReviewRequest
): Promise<GetFileReviewResponse> {
const review = await this.fileReviewStore.getReviewDetail(request.reviewId)
return review ? { review } : { error: 'review session not found' }
}
public async validateCommit(
request: ValidateDraftCommitRequest
): Promise<ValidateDraftCommitResponse> {
request: ValidateFileReviewCommitRequest
): Promise<ValidateFileReviewCommitResponse> {
return await this.commitService.validateCommit(request)
}
public async commitReview(
request: CommitDraftReviewRequest
): Promise<CommitDraftReviewResponse> {
request: CommitFileReviewRequest
): Promise<CommitFileReviewResponse> {
return await this.commitService.commitReview(request)
}
public async discardReview(
request: DiscardDraftReviewRequest
): Promise<DiscardDraftReviewResponse> {
const detail = await this.draftReviewStore.getReviewDetail(request.reviewId)
request: DiscardFileReviewRequest
): Promise<DiscardFileReviewResponse> {
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<ResolvedDocxOpenTarget> {
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<void> {
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,
@@ -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<DraftReviewDetail[]> {
}): Promise<FileReviewDetail[]> {
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
}
})
)
}
@@ -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<string>()
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
]
}
}
@@ -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<DraftReviewDocument, 'review_artifact_path'>
): string | undefined => document.review_artifact_path
export const getDraftArtifactPath = (
review: Pick<FileReview, 'draft_artifact_path'>
): string | undefined => review.draft_artifact_path
export const getWorkingArtifactPath = (
document: Pick<DraftReviewDocument, 'working_artifact_path'>
): string | undefined => document.working_artifact_path
review: Pick<FileReview, 'working_artifact_path'>
): string | undefined => review.working_artifact_path
export const getCommittedViewPath = (
document: Pick<DraftReviewDocument, 'committed_artifact_path'>
): string | undefined => document.committed_artifact_path
export const getCommittedArtifactPath = (
review: Pick<FileReview, 'committed_artifact_path'>
): string | undefined => review.committed_artifact_path
+4
View File
@@ -30,6 +30,8 @@ import {
humanTaskInputSchema,
humanTaskOutputSchema,
humanTraceStepSchema,
runtimeArtifactsSummarySchema,
runtimeChangedFileSchema,
humanWatchHintSchema,
skillDefinitionBaseSchema,
toolCallDiagnosticSchema,
@@ -91,6 +93,8 @@ export type HumanTaskInput = z.infer<typeof humanTaskInputSchema>
export type HumanTaskOutput = z.infer<typeof humanTaskOutputSchema>
export type HumanExecution = z.infer<typeof humanExecutionSchema>
export type HumanState = z.infer<typeof humanStateContractSchema>
export type RuntimeChangedFile = z.infer<typeof runtimeChangedFileSchema>
export type RuntimeArtifactsSummary = z.infer<typeof runtimeArtifactsSummarySchema>
export type HumanRoundExecution = z.infer<typeof humanRoundExecutionSchema>
export type HumanRoundRecord = z.infer<typeof humanRoundRecordSchema>
export type HumanLoadedHistoryContext = z.infer<typeof humanLoadedHistoryContextSchema>
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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<AiDraftReviewRef> = z.object({
export const aiFileReviewRefSchema: z.ZodType<AiFileReviewRef> = 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<AiFileArtifactRef> = z.object({
summary: z.string().min(1)
})
export const aiDraftReviewArtifactRefSchema: z.ZodType<AiDraftReviewArtifactRef> = z.object({
export const aiFileReviewArtifactRefSchema: z.ZodType<AiFileReviewArtifactRef> = z.object({
kind: z.literal('draft_review'),
review: aiDraftReviewRefSchema,
review: aiFileReviewRefSchema,
summary: z.string().min(1)
})
export const aiArtifactRefSchema: z.ZodType<AiArtifactRef> = z.union([
aiFileArtifactRefSchema,
aiDraftReviewArtifactRefSchema
aiFileReviewArtifactRefSchema
])
export const aiRunStatusSchema = z.enum(AI_RUN_STATUSES)
+4 -4
View File
@@ -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<void>
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<ResolvedDocxOpenTarget>
beginDocxReviewRun: () => void
finalizeDocxReviewRun: () => Promise<DraftReviewDetail[]>
finalizeDocxReviewRun: () => Promise<FileReviewDetail[]>
discardDocxReviewRun: () => void
documentsStore: DocumentsStore
embeddingService: EmbeddingService
+64 -55
View File
@@ -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
}))
})
+3 -3
View File
@@ -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)
+22 -36
View File
@@ -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),
@@ -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<string, PrimitiveRowValue>
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 = <T extends DraftWriteRow>(value: T): Partial<T> => {
const next: Partial<T> = {}
for (const key of Object.keys(value) as Array<keyof T>) {
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<void> {
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<ListDraftReviewsResponse> {
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<DraftReviewSummary | null> {
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<void> {
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<DraftReviewDocument[]> {
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<void> {
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<DraftReviewChange[]> {
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<void> {
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<DraftCommitJob[]> {
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<void> {
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<DraftReviewDetail | null> {
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<void> {
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<DraftReviewDetail[]> {
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)
}
}
@@ -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<string, PrimitiveRowValue>
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 = <T extends WriteRow>(value: T): Partial<T> => {
const next: Partial<T> = {}
for (const key of Object.keys(value) as Array<keyof T>) {
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<void> {
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<ListFileReviewsResponse> {
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<FileReview | null> {
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<void> {
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<FileReviewChange[]> {
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<void> {
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<CommitAttempt[]> {
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<void> {
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<void> {
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)
}
}
+44 -42
View File
@@ -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,
+17 -17
View File
@@ -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<AiRunApprovalDecisionResponse>
draftReviewList: (payload?: ListDraftReviewsRequest) => Promise<ListDraftReviewsResponse>
draftReviewGet: (payload: GetDraftReviewRequest) => Promise<GetDraftReviewResponse>
draftReviewValidate: (payload: ValidateDraftCommitRequest) => Promise<ValidateDraftCommitResponse>
draftReviewCommit: (payload: CommitDraftReviewRequest) => Promise<CommitDraftReviewResponse>
draftReviewDiscard: (
payload: DiscardDraftReviewRequest
) => Promise<DiscardDraftReviewResponse>
fileReviewList: (payload?: ListFileReviewsRequest) => Promise<ListFileReviewsResponse>
fileReviewGet: (payload: GetFileReviewRequest) => Promise<GetFileReviewResponse>
fileReviewValidate: (payload: ValidateFileReviewCommitRequest) => Promise<ValidateFileReviewCommitResponse>
fileReviewCommit: (payload: CommitFileReviewRequest) => Promise<CommitFileReviewResponse>
fileReviewDiscard: (
payload: DiscardFileReviewRequest
) => Promise<DiscardFileReviewResponse>
pickApplication: () => Promise<PickApplicationResponse>
openPathWithApplication: (
payload: OpenPathWithApplicationRequest
+11 -11
View File
@@ -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) => {
@@ -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<WorkbenchContentPanelProps> = ({
state,
activeReview,
draftReviewLoading,
fileReviewLoading,
draftCommitValidation,
draftCommitSubmitting,
draftDiscardSubmitting,
@@ -190,7 +190,7 @@ const WorkbenchContentPanel: React.FC<WorkbenchContentPanelProps> = ({
{state.kind === 'review_workspace' ? (
<WorkbenchReviewPanel
review={activeReview}
loading={draftReviewLoading}
loading={fileReviewLoading}
validation={draftCommitValidation}
committing={draftCommitSubmitting}
discarding={draftDiscardSubmitting}
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Spin } from 'antd'
import { HiChevronLeft, HiChevronRight } from 'react-icons/hi2'
import type { DraftReviewDetail, ValidateDraftCommitResponse } from '../../../../shared/types'
import type { FileReviewDetail, ValidateFileReviewCommitResponse } from '../../../../shared/types'
import EmbeddedFileViewer from '../fileRenders/EmbeddedFileViewer'
import { REVIEW_STATUS_META } from './constants'
import type { ReviewWorkspaceTab } from './types'
@@ -9,7 +9,7 @@ import { workbenchSelectors } from '../../testing/selectors'
const ACTIVE_CONFIRM_STATUSES = new Set(['pending_confirm', 'conflicted', 'failed'])
const getEndedStateMessage = (status: DraftReviewDetail['review']['status']): string => {
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<WorkbenchReviewPanelProps> = ({
}) => {
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<WorkbenchReviewPanelProps> = ({
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<WorkbenchReviewPanelProps> = ({
)
}
if (!review || !selectedDocument) {
if (!review) {
return (
<div
className="ui-inset flex h-full items-center justify-center border-dashed text-sm text-[#999999]"
@@ -118,7 +113,7 @@ const WorkbenchReviewPanel: React.FC<WorkbenchReviewPanelProps> = ({
data-testid={workbenchSelectors.reviewTabs}
>
{tabs.map((tab) => {
const active = tab.fileKey === selectedDocument.source_path
const active = tab.fileKey === review.review.source_path
return (
<button
key={tab.fileKey}
@@ -1,5 +1,5 @@
import type { AssistantStepStatus, WorkbenchFileOrigin } from './types'
import type { DraftReviewStatus } from '../../../../shared/types'
import type { FileReviewStatus } from '../../../../shared/types'
export const WORKBENCH_SIDEBAR_STORAGE_KEY = 'workbench.sidebar.collapsed'
export const WORKBENCH_CONTENT_PANEL_COLLAPSED_STORAGE_KEY = 'workbench.content-panel.collapsed'
@@ -14,7 +14,7 @@ export const ASSISTANT_STEP_STATUS_META: Record<
failed: { label: '失败', className: 'border-[#F7D5D9] bg-[#FFF2F4] text-[#B5455D]' }
}
export const REVIEW_STATUS_META: Record<DraftReviewStatus, { label: string; className: string }> = {
export const REVIEW_STATUS_META: Record<FileReviewStatus, { label: string; className: string }> = {
pending_confirm: {
label: '待确认修改',
className: 'border-[#F4E5DF] bg-[#FBF5F2] text-[#D97757]'
@@ -1,11 +1,11 @@
import type { DraftReviewSummary } from '../../../../shared/types'
import type { FileReview } from '../../../../shared/types'
import type { AssistantTurnReviewTarget } from './types'
export const buildReviewTarget = (review: DraftReviewSummary): AssistantTurnReviewTarget => ({
export const buildReviewTarget = (review: FileReview): AssistantTurnReviewTarget => ({
reviewId: review.id,
title: review.title,
changeCount: review.change_count,
documentCount: review.document_count,
documentCount: 1,
status: review.status
})
@@ -18,9 +18,9 @@ export const mergeReviewTargets = (
]
export const groupReviewTargetsByRunId = (
reviews: DraftReviewSummary[]
reviews: FileReview[]
): Map<string, AssistantTurnReviewTarget[]> => {
const grouped = new Map<string, DraftReviewSummary[]>()
const grouped = new Map<string, FileReview[]>()
reviews.forEach((review) => {
const existing = grouped.get(review.run_id) || []
existing.push(review)
@@ -1,6 +1,6 @@
import type {
DraftDocumentFormat,
DraftReviewStatus,
FileReviewStatus,
SearchResult
} from '../../../../shared/types'
@@ -38,7 +38,7 @@ export type ReviewWorkspaceTab = {
fileName: string
sourcePath: string
changeCount: number
status: DraftReviewStatus
status: FileReviewStatus
unseen: boolean
}
@@ -57,7 +57,7 @@ export type AssistantTurnReviewTarget = {
title: string
changeCount: number
documentCount: number
status?: DraftReviewStatus
status?: FileReviewStatus
}
export type AssistantTurnBlocker = {
+20 -18
View File
@@ -1,7 +1,7 @@
import type {
DraftReviewChange,
DraftReviewDocument,
DraftReviewRenderKind,
FileReviewChange,
FileReview,
FileReviewRenderKind,
DraftTextDiffHunk,
IpcJsonArray,
IpcJsonObject,
@@ -176,7 +176,7 @@ export const getStepFiles = (step: AssistantTurnStep): WorkbenchFileRef[] => {
return dedupeFileRefs(step.files)
}
export const getReviewRenderKind = (document: DraftReviewDocument): DraftReviewRenderKind => {
export const getReviewRenderKind = (document: FileReview): FileReviewRenderKind => {
if (document.review_render_kind) return document.review_render_kind
if (document.format === 'docx') return 'docx_block'
if (document.format === 'md' || document.format === 'txt') return 'text_hunk'
@@ -185,20 +185,22 @@ export const getReviewRenderKind = (document: DraftReviewDocument): DraftReviewR
return 'preview_only'
}
export const formatLocatorSummary = (change: DraftReviewChange): string => {
const section = typeof change.locator.section === 'string' ? change.locator.section : null
const heading = typeof change.locator.heading === 'string' ? change.locator.heading : null
const blockId = typeof change.locator.blockId === 'string' ? change.locator.blockId : null
const action = typeof change.locator.action === 'string' ? change.locator.action : null
export const formatLocatorSummary = (change: FileReviewChange): string => {
const locator = change.locator
if (!locator) return '定位信息未结构化'
const section = typeof locator.section === 'string' ? locator.section : null
const heading = typeof locator.heading === 'string' ? locator.heading : null
const blockId = typeof locator.blockId === 'string' ? locator.blockId : null
const action = typeof locator.action === 'string' ? locator.action : null
const paragraphIndex =
typeof change.locator.paragraphIndex === 'number'
? `第 ${change.locator.paragraphIndex + 1} 段`
typeof locator.paragraphIndex === 'number'
? `第 ${locator.paragraphIndex + 1} 段`
: null
const rowIndex =
typeof change.locator.rowIndex === 'number' ? `第 ${change.locator.rowIndex + 1} 行` : null
typeof locator.rowIndex === 'number' ? `第 ${locator.rowIndex + 1} 行` : null
const columnIndex =
typeof change.locator.columnIndex === 'number'
? `第 ${change.locator.columnIndex + 1} 列`
typeof locator.columnIndex === 'number'
? `第 ${locator.columnIndex + 1} 列`
: null
return (
@@ -209,10 +211,10 @@ export const formatLocatorSummary = (change: DraftReviewChange): string => {
)
}
export const buildFallbackDiffHunks = (change: DraftReviewChange): DraftTextDiffHunk[] => {
export const buildFallbackDiffHunks = (change: FileReviewChange): DraftTextDiffHunk[] => {
if (change.diff_hunks && change.diff_hunks.length > 0) return change.diff_hunks
const beforeLines = change.before_text.split('\n')
const afterLines = change.after_text.split('\n')
const beforeLines = String(change.before_text || '').split('\n')
const afterLines = String(change.after_text || '').split('\n')
return [
{
id: change.id,
@@ -226,7 +228,7 @@ export const buildFallbackDiffHunks = (change: DraftReviewChange): DraftTextDiff
]
}
export const buildUnifiedDiffText = (change: DraftReviewChange): string => {
export const buildUnifiedDiffText = (change: FileReviewChange): string => {
return buildFallbackDiffHunks(change)
.flatMap((hunk) =>
hunk.lines.map(
+63 -63
View File
@@ -15,12 +15,12 @@ import type {
AiRunEvent,
ChatMessage,
ChatSession,
DraftReviewDetail,
DraftReviewSummary,
FileReviewDetail,
FileReview,
LLMConfig,
SearchResult,
SelectedReference,
ValidateDraftCommitResponse,
ValidateFileReviewCommitResponse,
WorkspaceSource,
WorkspaceRuntimeBinding
} from '../../../shared/types'
@@ -84,14 +84,14 @@ const createAssistantTurn = (messageId: string): AssistantTurnViewModel => ({
blockers: []
})
const ACTIVE_REVIEW_WORKSPACE_STATUSES = new Set<DraftReviewSummary['status']>([
const ACTIVE_REVIEW_WORKSPACE_STATUSES = new Set<FileReview['status']>([
'pending_confirm',
'conflicted',
'failed'
])
const sortDraftReviews = (items: DraftReviewSummary[]): DraftReviewSummary[] => {
const priority: Record<DraftReviewSummary['status'], number> = {
const sortFileReviews = (items: FileReview[]): FileReview[] => {
const priority: Record<FileReview['status'], number> = {
pending_confirm: 0,
conflicted: 1,
failed: 2,
@@ -153,10 +153,10 @@ const ChatPage: React.FC = () => {
return stored === null ? true : stored === 'true'
})
const [activeDraftReview, setActiveDraftReview] = useState<DraftReviewDetail | null>(null)
const [draftReviewLoading, setDraftReviewLoading] = useState(false)
const [activeFileReview, setActiveFileReview] = useState<FileReviewDetail | null>(null)
const [fileReviewLoading, setFileReviewLoading] = useState(false)
const [draftCommitValidation, setDraftCommitValidation] =
useState<ValidateDraftCommitResponse | null>(null)
useState<ValidateFileReviewCommitResponse | null>(null)
const [draftCommitSubmitting, setDraftCommitSubmitting] = useState(false)
const [draftDiscardSubmitting, setDraftDiscardSubmitting] = useState(false)
@@ -270,10 +270,10 @@ const ChatPage: React.FC = () => {
}
}
if (contentPanel.kind === 'review_workspace' && activeDraftReview) {
const statusMeta = REVIEW_STATUS_META[activeDraftReview.review.status]
if (contentPanel.kind === 'review_workspace' && activeFileReview) {
const statusMeta = REVIEW_STATUS_META[activeFileReview.review.status]
return {
title: activeDraftReview.documents[0]?.source_name || contentPanel.title,
title: activeFileReview.review.source_name || contentPanel.title,
badge: statusMeta.label,
badgeClassName: statusMeta.className
}
@@ -286,9 +286,9 @@ const ChatPage: React.FC = () => {
}
}, [contentPanel, contentPanelCollapsed])
const resetDraftReviewState = (): void => {
setActiveDraftReview(null)
setDraftReviewLoading(false)
const resetFileReviewState = (): void => {
setActiveFileReview(null)
setFileReviewLoading(false)
setDraftCommitValidation(null)
setDraftCommitSubmitting(false)
setDraftDiscardSubmitting(false)
@@ -386,42 +386,42 @@ const ChatPage: React.FC = () => {
}
}
const loadDraftReviewDetail = async (input: {
const loadFileReviewDetail = async (input: {
reviewId: string
detail?: DraftReviewDetail | null
detail?: FileReviewDetail | null
tabs?: ReviewWorkspaceTab[]
activeFileKey?: string | null
openWorkspace?: boolean
}): Promise<void> => {
setDraftReviewLoading(true)
setFileReviewLoading(true)
try {
const detailResponse =
input.detail !== undefined ? { review: input.detail } : await window.api.draftReviewGet({ reviewId: input.reviewId })
input.detail !== undefined ? { review: input.detail } : await window.api.fileReviewGet({ reviewId: input.reviewId })
if (!detailResponse.review) {
setActiveDraftReview(null)
setActiveFileReview(null)
setDraftCommitValidation(null)
return
}
setActiveDraftReview(detailResponse.review)
setActiveFileReview(detailResponse.review)
setDraftCommitValidation(null)
if (input.openWorkspace) {
showReviewWorkspace(
input.tabs || reviewWorkspaceTabs,
input.activeFileKey || detailResponse.review.documents[0]?.source_path || null
input.activeFileKey || detailResponse.review.review.source_path || null
)
}
} catch (error) {
console.error('Failed to load draft review detail', error)
setActiveDraftReview(null)
setActiveFileReview(null)
setDraftCommitValidation(null)
} finally {
setDraftReviewLoading(false)
setFileReviewLoading(false)
}
}
const loadDraftReviewsForSession = async (
const loadFileReviewsForSession = async (
sessionId: string,
options?: {
preferredReviewId?: string | null
@@ -433,13 +433,13 @@ const ChatPage: React.FC = () => {
const shouldWaitForCompleteDetails =
Boolean(options?.forceOpenWorkspace) || Boolean(options?.preferredReviewId)
const maxAttempts = shouldWaitForCompleteDetails ? 6 : 1
let nextItems: DraftReviewSummary[] = []
let activeItems: DraftReviewSummary[] = []
let activeDetails: DraftReviewDetail[] = []
let nextItems: FileReview[] = []
let activeItems: FileReview[] = []
let activeDetails: FileReviewDetail[] = []
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await window.api.draftReviewList({ sessionId })
nextItems = sortDraftReviews(response.items)
const response = await window.api.fileReviewList({ sessionId })
nextItems = sortFileReviews(response.items)
activeItems = nextItems.filter((item) =>
ACTIVE_REVIEW_WORKSPACE_STATUSES.has(item.status)
)
@@ -450,11 +450,11 @@ const ChatPage: React.FC = () => {
}
const activeDetailsResponses = await Promise.all(
activeItems.map((item) => window.api.draftReviewGet({ reviewId: item.id }))
activeItems.map((item) => window.api.fileReviewGet({ reviewId: item.id }))
)
activeDetails = activeDetailsResponses
.map((item) => item.review)
.filter((item): item is DraftReviewDetail => Boolean(item))
.filter((item): item is FileReviewDetail => Boolean(item))
.sort((left, right) => right.review.updated_at - left.review.updated_at)
const missingPreferredReview =
@@ -488,7 +488,7 @@ const ChatPage: React.FC = () => {
if (activeItems.length === 0) {
setReviewWorkspaceTabs([])
setActiveDraftReview(null)
setActiveFileReview(null)
setDraftCommitValidation(null)
setContentPanel((current) =>
current.kind === 'review_workspace' ? createEmptyPanel() : current
@@ -496,9 +496,9 @@ const ChatPage: React.FC = () => {
return
}
const latestByFileKey = new Map<string, DraftReviewDetail>()
const latestByFileKey = new Map<string, FileReviewDetail>()
activeDetails.forEach((detail) => {
const fileKey = detail.documents[0]?.source_path
const fileKey = detail.review.source_path
if (!fileKey || latestByFileKey.has(fileKey)) return
latestByFileKey.set(fileKey, detail)
})
@@ -506,17 +506,17 @@ const ChatPage: React.FC = () => {
const currentWorkspaceFileKey =
contentPanel.kind === 'review_workspace'
? contentPanel.activeFileKey
: activeDraftReview?.documents[0]?.source_path || null
: activeFileReview?.review.source_path || null
const previewFileKey =
contentPanel.kind === 'file_preview' ? contentPanel.file.path : null
const preferredFileKey =
options?.preferredReviewId
? [...latestByFileKey.values()].find(
(detail) => detail.review.id === options.preferredReviewId
)?.documents[0]?.source_path || null
)?.review.source_path || null
: null
const nextTabs = [...latestByFileKey.values()].map((detail) => {
const fileKey = detail.documents[0]?.source_path || detail.review.id
const fileKey = detail.review.source_path || detail.review.id
const previousTab = reviewWorkspaceTabs.find((tab) => tab.fileKey === fileKey)
const shouldMarkUnseen =
previousTab?.reviewId !== detail.review.id &&
@@ -527,7 +527,7 @@ const ChatPage: React.FC = () => {
return {
fileKey,
reviewId: detail.review.id,
fileName: detail.documents[0]?.source_name || detail.review.title,
fileName: detail.review.source_name || detail.review.title,
sourcePath: fileKey,
changeCount: detail.review.pending_change_count,
status: detail.review.status,
@@ -577,7 +577,7 @@ const ChatPage: React.FC = () => {
(contentPanel.kind === 'empty' ||
(previewFileKey !== null && previewFileKey === nextSelectedTab.fileKey)))
await loadDraftReviewDetail({
await loadFileReviewDetail({
reviewId: nextSelectedTab.reviewId,
detail: nextSelectedDetail,
tabs: normalizedTabs,
@@ -586,7 +586,7 @@ const ChatPage: React.FC = () => {
})
} catch (error) {
console.error('Failed to load draft reviews', error)
setActiveDraftReview(null)
setActiveFileReview(null)
setDraftCommitValidation(null)
}
}
@@ -643,13 +643,13 @@ const ChatPage: React.FC = () => {
setPendingWorkspaceRequest(null)
setAssistantTurns({})
setContentPanel(createEmptyPanel())
resetDraftReviewState()
void loadDraftReviewsForSession(currentSessionId, { autoOpen: true })
resetFileReviewState()
void loadFileReviewsForSession(currentSessionId, { autoOpen: true })
} else {
setCurrentMessages([])
setAssistantTurns({})
setModalPreviewFile(null)
resetDraftReviewState()
resetFileReviewState()
}
}, [currentSessionId])
@@ -722,9 +722,9 @@ const ChatPage: React.FC = () => {
setModalPreviewFile(fileRef)
}
const openDraftReview = (reviewId: string): void => {
const openFileReview = (reviewId: string): void => {
if (currentSessionId) {
void loadDraftReviewsForSession(currentSessionId, {
void loadFileReviewsForSession(currentSessionId, {
preferredReviewId: reviewId,
forceOpenWorkspace: true
})
@@ -738,7 +738,7 @@ const ChatPage: React.FC = () => {
)
: reviewWorkspaceTabs
setReviewWorkspaceTabs(nextTabs)
void loadDraftReviewDetail({
void loadFileReviewDetail({
reviewId,
tabs: nextTabs,
activeFileKey: nextTab?.fileKey || null,
@@ -925,7 +925,7 @@ const ChatPage: React.FC = () => {
message.success(`审阅稿已生成:${event.review.changeCount} 处修改`)
const shouldAutoOpen =
contentPanel.kind === 'review_workspace' || contentPanel.kind === 'file_preview'
void loadDraftReviewsForSession(currentSessionId, {
void loadFileReviewsForSession(currentSessionId, {
preferredReviewId: event.review.reviewId,
autoOpen: shouldAutoOpen
})
@@ -1342,19 +1342,19 @@ const ChatPage: React.FC = () => {
}
const handleDraftCommit = async (): Promise<void> => {
if (!activeDraftReview || !currentSessionId || draftCommitSubmitting) return
if (!activeFileReview || !currentSessionId || draftCommitSubmitting) return
setDraftCommitSubmitting(true)
try {
const validation = await window.api.draftReviewValidate({
reviewId: activeDraftReview.review.id
const validation = await window.api.fileReviewValidate({
reviewId: activeFileReview.review.id
})
setDraftCommitValidation(validation)
if (!validation.ready) {
message.warning('提交前校验未通过,请先处理提示问题。')
return
}
const response = await window.api.draftReviewCommit({
reviewId: activeDraftReview.review.id,
const response = await window.api.fileReviewCommit({
reviewId: activeFileReview.review.id,
overwrite: true
})
if (!response.success) {
@@ -1362,7 +1362,7 @@ const ChatPage: React.FC = () => {
} else {
emitSuccessMessage('审阅稿已写回源文件')
}
await loadDraftReviewsForSession(currentSessionId, { autoOpen: true })
await loadFileReviewsForSession(currentSessionId, { autoOpen: true })
} catch (error) {
console.error(error)
message.error('提交草稿失败')
@@ -1372,11 +1372,11 @@ const ChatPage: React.FC = () => {
}
const handleDraftDiscard = async (): Promise<void> => {
if (!activeDraftReview || !currentSessionId || draftDiscardSubmitting) return
if (!activeFileReview || !currentSessionId || draftDiscardSubmitting) return
setDraftDiscardSubmitting(true)
try {
const discardedReviewId = activeDraftReview.review.id
const response = await window.api.draftReviewDiscard({
const discardedReviewId = activeFileReview.review.id
const response = await window.api.fileReviewDiscard({
reviewId: discardedReviewId
})
if (!response.success) {
@@ -1402,7 +1402,7 @@ const ChatPage: React.FC = () => {
setDraftCommitValidation(null)
if (remainingTabs.length === 0) {
setActiveDraftReview(null)
setActiveFileReview(null)
setContentPanel((current) =>
current.kind === 'review_workspace' ? createEmptyPanel() : current
)
@@ -1411,7 +1411,7 @@ const ChatPage: React.FC = () => {
const nextTab = remainingTabs[0]
showReviewWorkspace(remainingTabs, nextTab.fileKey)
await loadDraftReviewDetail({
await loadFileReviewDetail({
reviewId: nextTab.reviewId,
tabs: remainingTabs,
activeFileKey: nextTab.fileKey,
@@ -1530,7 +1530,7 @@ const ChatPage: React.FC = () => {
onOpenFile={(file) => {
void openFile(file)
}}
onOpenReview={openDraftReview}
onOpenReview={openFileReview}
/>
)
}
@@ -1884,8 +1884,8 @@ const ChatPage: React.FC = () => {
>
<WorkbenchContentPanel
state={contentPanel}
activeReview={activeDraftReview}
draftReviewLoading={draftReviewLoading}
activeReview={activeFileReview}
fileReviewLoading={fileReviewLoading}
draftCommitValidation={draftCommitValidation}
draftCommitSubmitting={draftCommitSubmitting}
draftDiscardSubmitting={draftDiscardSubmitting}
@@ -1898,7 +1898,7 @@ const ChatPage: React.FC = () => {
tab.fileKey === fileKey ? { ...tab, unseen: false } : tab
)
setReviewWorkspaceTabs(nextTabs)
void loadDraftReviewDetail({
void loadFileReviewDetail({
reviewId: nextTab.reviewId,
tabs: nextTabs,
activeFileKey: fileKey,
+76 -68
View File
@@ -375,7 +375,7 @@ export type DraftChangeKind =
| 'worksheet_range'
| 'slide_object'
| 'page_region'
export type DraftReviewRenderKind =
export type FileReviewRenderKind =
| 'docx_block'
| 'text_hunk'
| 'sheet_range'
@@ -383,7 +383,7 @@ export type DraftReviewRenderKind =
| 'preview_only'
export type DraftChangeDecision = 'pending' | 'accepted' | 'rejected'
export type DraftChangeResolution = Exclude<DraftChangeDecision, 'pending'>
export type DraftReviewStatus =
export type FileReviewStatus =
| 'pending_confirm'
| 'committing'
| 'committed'
@@ -392,8 +392,8 @@ export type DraftReviewStatus =
| 'superseded'
| 'failed'
export type DraftArtifactRole = 'draft' | 'committed'
export type DraftCommitJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'conflicted'
export type DraftCommitValidationIssueCode =
export type CommitAttemptStatus = 'pending' | 'running' | 'completed' | 'failed' | 'conflicted'
export type CommitValidationIssueCode =
| 'source_changed'
| 'session_not_found'
| 'session_not_ready'
@@ -409,13 +409,28 @@ export interface DraftSourceVersion {
content_hash?: string
}
export interface DraftReviewSummary {
/**
* FileReview is the single-file review envelope shared by supported file editors.
* Only docx is implemented today. Future xlsx/pptx support should reuse this
* outer shape while keeping format-specific change generation and commit rules
* inside each file subsystem.
*/
export interface FileReview {
id: string
session_id: string
run_id: string
status: DraftReviewStatus
status: FileReviewStatus
title: string
document_count: number
source_document_id?: string
source_path: string
source_name: string
format: DraftDocumentFormat
review_render_kind?: FileReviewRenderKind
latest_draft_snapshot_id: string
draft_artifact_path?: string
working_artifact_path?: string
committed_artifact_path?: string
source_version: DraftSourceVersion
change_count: number
pending_change_count: number
accepted_change_count: number
@@ -427,40 +442,18 @@ export interface DraftReviewSummary {
last_error?: string
}
export interface DraftReviewDocument {
export interface FileReviewChange {
id: string
review_id: string
source_document_id?: string
source_path: string
source_name: string
format: DraftDocumentFormat
review_render_kind?: DraftReviewRenderKind
latest_draft_snapshot_id: string
review_artifact_path?: string
working_artifact_path?: string
committed_artifact_path?: string
source_version: DraftSourceVersion
total_change_count: number
pending_change_count: number
accepted_change_count: number
rejected_change_count: number
created_at: number
updated_at: number
}
export interface DraftReviewChange {
id: string
review_id: string
document_id: string
order_index: number
change_kind: DraftChangeKind
title: string
summary: string
decision: DraftChangeDecision
before_text: string
after_text: string
before_text?: string
after_text?: string
anchor_name?: string
locator: JsonObject
locator?: JsonObject
diff_hunks?: DraftTextDiffHunk[]
source_step_ref?: string
metadata?: JsonObject
@@ -480,88 +473,103 @@ export interface DraftTextDiffHunk {
lines: DraftTextDiffHunkLine[]
}
export interface DraftCommitResultItem {
draft_document_id: string
export interface CommitResultItem {
review_id: string
output_path: string
operation: AiFileArtifactOperation
checksum?: string
byte_size?: number
}
export interface DraftCommitJob {
/**
* CommitAttempt records submit-to-source executions for a FileReview.
* It is lifecycle history, not change content.
*/
export interface CommitAttempt {
id: string
review_id: string
status: DraftCommitJobStatus
document_count: number
results: DraftCommitResultItem[]
status: CommitAttemptStatus
results: CommitResultItem[]
started_at: number
completed_at?: number
error?: string
}
export interface DraftReviewDetail {
review: DraftReviewSummary
documents: DraftReviewDocument[]
changes: DraftReviewChange[]
commit_jobs: DraftCommitJob[]
export interface FileReviewDetail {
review: FileReview
changes: FileReviewChange[]
commit_attempts: CommitAttempt[]
}
export interface ListDraftReviewsRequest {
export interface ListFileReviewsRequest {
sessionId?: string
status?: DraftReviewStatus
status?: FileReviewStatus
}
export interface ListDraftReviewsResponse {
items: DraftReviewSummary[]
export interface ListFileReviewsResponse {
items: FileReview[]
total: number
}
export interface GetDraftReviewRequest {
export interface GetFileReviewRequest {
reviewId: string
}
export interface GetDraftReviewResponse {
review?: DraftReviewDetail
export interface GetFileReviewResponse {
review?: FileReviewDetail
error?: string
}
export interface DraftCommitValidationIssue {
code: DraftCommitValidationIssueCode
export interface CommitValidationIssue {
code: CommitValidationIssueCode
message: string
documentId?: string
sourcePath?: string
}
export interface ValidateDraftCommitRequest {
export interface ValidateFileReviewCommitRequest {
reviewId: string
}
export interface ValidateDraftCommitResponse {
export interface ValidateFileReviewCommitResponse {
ready: boolean
issues: DraftCommitValidationIssue[]
issues: CommitValidationIssue[]
}
export interface CommitDraftReviewRequest {
export interface CommitFileReviewRequest {
reviewId: string
overwrite?: boolean
}
export interface CommitDraftReviewResponse {
export interface CommitFileReviewResponse {
success: boolean
job?: DraftCommitJob
job?: CommitAttempt
error?: string
}
export interface DiscardDraftReviewRequest {
export interface DiscardFileReviewRequest {
reviewId: string
}
export interface DiscardDraftReviewResponse {
export interface DiscardFileReviewResponse {
success: boolean
review?: DraftReviewSummary
review?: FileReview
error?: string
}
export interface RuntimeChangedFile {
file_name: string
source_path: string
format: 'docx'
change_count: number
summary: string
change_highlights: string[]
draft_artifact_path?: string
}
export interface RuntimeArtifactsSummary {
changed_files: RuntimeChangedFile[]
}
export interface PickApplicationResponse {
canceled: boolean
appPath?: string
@@ -677,17 +685,17 @@ export type AiDraftSnapshotRef = {
format?: DraftDocumentFormat
sourcePath?: string
}
export type AiDraftReviewRef = {
export type AiFileReviewRef = {
reviewId: string
documentCount: number
changeCount: number
}
export type AiDraftReviewArtifactRef = {
export type AiFileReviewArtifactRef = {
kind: 'draft_review'
review: AiDraftReviewRef
review: AiFileReviewRef
summary: string
}
export type AiArtifactRef = AiFileArtifactRef | AiDraftReviewArtifactRef
export type AiArtifactRef = AiFileArtifactRef | AiFileReviewArtifactRef
export type AiRunStartRequest = {
sessionId: string
@@ -860,7 +868,7 @@ type AiRunEventPayload =
| {
type: 'draft_review_ready'
runId: string
review: AiDraftReviewRef
review: AiFileReviewRef
createdAt: number
}
| {
@@ -13,7 +13,7 @@ import {
collectDocxApplyOpsContentKinds,
filterAiRunEventsByType,
hasDraftFileArtifact,
hasDraftReviewArtifact,
hasFileReviewArtifact,
runWithStructuredOutputRetry
} from '../testUtils'
@@ -134,7 +134,7 @@ describe('ai human live backend', () => {
) &&
changedContentKinds.includes('paragraph') &&
filterAiRunEventsByType(events, 'draft_review_ready').length >= 1 &&
hasDraftReviewArtifact(result.output?.artifacts) &&
hasFileReviewArtifact(result.output?.artifacts) &&
hasDraftFileArtifact(result.output?.artifacts) &&
unchangedFixtures.every(Boolean) &&
/(过拟合|过度拟合)/.test(deliveryText) &&
@@ -13,7 +13,7 @@ import {
collectDocxApplyOpsContentKinds,
filterAiRunEventsByType,
hasDraftFileArtifact,
hasDraftReviewArtifact,
hasFileReviewArtifact,
runWithStructuredOutputRetry
} from '../testUtils'
@@ -123,7 +123,7 @@ describe('ai human live backend', () => {
changedContentKinds.includes('paragraph') &&
changedContentKinds.includes('table') &&
filterAiRunEventsByType(events, 'draft_review_ready').length >= 1 &&
hasDraftReviewArtifact(result.output?.artifacts) &&
hasFileReviewArtifact(result.output?.artifacts) &&
hasDraftFileArtifact(result.output?.artifacts) &&
unchangedFixtures.every(Boolean) &&
Boolean(targetFixture) &&
@@ -11,7 +11,7 @@ import {
} from '../../../harness/backend/aiLiveTestData'
import {
countDraftFileArtifacts,
countDraftReviewArtifacts,
countFileReviewArtifacts,
collectToolDiagnosticMessages,
filterAiRunEventsByType,
runWithStructuredOutputRetry
@@ -133,7 +133,7 @@ describe('ai human live backend', () => {
successfulWriteResults.length === 0 &&
failedWriteResults.length >= 1 &&
diagnosticMessages.some((message) => message.includes('当前命中复杂定位排版区域')) &&
countDraftReviewArtifacts(result.output?.artifacts) === 0 &&
countFileReviewArtifacts(result.output?.artifacts) === 0 &&
countDraftFileArtifacts(result.output?.artifacts) === 0 &&
/(手动|版式|排版|没法自动|无法自动|复杂定位)/.test(deliveryText) &&
unchangedFixtures.every(Boolean) &&
@@ -13,7 +13,7 @@ import {
import {
collectDocxApplyOpsContentKinds,
countDraftFileArtifacts,
countDraftReviewArtifacts,
countFileReviewArtifacts,
filterAiRunEventsByType,
hasKbToolCall,
runWithStructuredOutputRetry
@@ -120,7 +120,7 @@ describe('ai human live backend', () => {
sourceDocsOpened &&
targetDocsWritten &&
filterAiRunEventsByType(events, 'draft_review_ready').length >= 2 &&
countDraftReviewArtifacts(result.output?.artifacts) >= 2 &&
countFileReviewArtifacts(result.output?.artifacts) >= 2 &&
countDraftFileArtifacts(result.output?.artifacts) >= 2 &&
/(过拟合|过度拟合)/.test(deliveryText) &&
/反走样/.test(deliveryText) &&
@@ -15,7 +15,7 @@ import {
filterAiRunEventsByType,
hasDocxWriteToolCall,
hasDraftFileArtifact,
hasDraftReviewArtifact,
hasFileReviewArtifact,
hasKbToolCall
} from '../testUtils'
@@ -149,7 +149,7 @@ describe('ai human live backend', () => {
kbEvidenceText.includes('Owner Note.md') &&
deliveryText.length > 0 &&
filterAiRunEventsByType(result.events, 'draft_review_ready').length >= 1 &&
hasDraftReviewArtifact(result.output?.artifacts) &&
hasFileReviewArtifact(result.output?.artifacts) &&
hasDraftFileArtifact(result.output?.artifacts),
{
name: 'human-live-risk-report-delivery-is-backed-by-evidence-and-review-artifacts',
@@ -55,7 +55,7 @@ describe('ai human mock-llm backend', () => {
ensureReadyCalls += 1
await runtime.ensureEmbeddingReady()
},
draftReviewService: runtime.draftReviewService,
fileReviewService: runtime.fileReviewService,
isCancelled: () => false,
sendEvent: (event) => runtime.eventCollector.add(event),
recordLlmIo: (record) => runtime.eventCollector.addLlmIo(record),
@@ -170,7 +170,7 @@ describe('ai human mock-llm backend', () => {
ensureEmbeddingReady: async () => {
throw new Error('embedding preflight failed for test')
},
draftReviewService: runtime.draftReviewService,
fileReviewService: runtime.fileReviewService,
isCancelled: () => false,
sendEvent: (event) => runtime.eventCollector.add(event),
recordLlmIo: (record) => runtime.eventCollector.addLlmIo(record),
@@ -43,7 +43,7 @@ describe('ai human mock-llm backend', () => {
ensureEmbeddingReady: async () => {
await runtime.ensureEmbeddingReady()
},
draftReviewService: runtime.draftReviewService,
fileReviewService: runtime.fileReviewService,
isCancelled: () => false,
sendEvent: (event) => runtime.eventCollector.add(event),
recordLlmIo: (record) => runtime.eventCollector.addLlmIo(record),
@@ -10,7 +10,7 @@ import {
collectDocxApplyOpsContentKinds,
filterAiRunEventsByType,
hasDraftFileArtifact,
hasDraftReviewArtifact,
hasFileReviewArtifact,
readLatestToolResultDataFromEvents
} from '../testUtils'
import { findParagraphBlockIdByText, type DocxOutlineResult } from '../../tool/docx/testUtils'
@@ -139,7 +139,7 @@ describe('ai human mock-llm backend', () => {
applyResult.changedBlocks.length === 1 &&
searchResult.matches.length === 1 &&
readResult.blocks.length === 1 &&
hasDraftReviewArtifact(result.output.artifacts) &&
hasFileReviewArtifact(result.output.artifacts) &&
hasDraftFileArtifact(result.output.artifacts) &&
filterAiRunEventsByType(result.events, 'draft_review_ready').length === 1,
{
@@ -317,7 +317,7 @@ describe('ai human mock-llm backend', () => {
tableEditResult.changedBlocks.some((block) => block.action === 'insert_after') &&
contentKinds.includes('paragraph') &&
contentKinds.includes('table') &&
hasDraftReviewArtifact(result.output.artifacts) &&
hasFileReviewArtifact(result.output.artifacts) &&
hasDraftFileArtifact(result.output.artifacts) &&
outlineResult.blocks.some(
(block) =>
+2 -2
View File
@@ -115,10 +115,10 @@ export const collectStartedToolStringArgs = (
export const hasKbToolCall = (events: readonly AiRunEvent[]): boolean =>
filterAiRunEventsByType(events, 'tool_call_result').some((event) => event.toolName.startsWith('kb_'))
export const hasDraftReviewArtifact = (artifacts: readonly AiArtifactRef[] | undefined): boolean =>
export const hasFileReviewArtifact = (artifacts: readonly AiArtifactRef[] | undefined): boolean =>
Array.isArray(artifacts) && artifacts.some((artifact) => artifact.kind === 'draft_review')
export const countDraftReviewArtifacts = (artifacts: readonly AiArtifactRef[] | undefined): number =>
export const countFileReviewArtifacts = (artifacts: readonly AiArtifactRef[] | undefined): number =>
Array.isArray(artifacts) ? artifacts.filter((artifact) => artifact.kind === 'draft_review').length : 0
export const hasDraftFileArtifact = (artifacts: readonly AiArtifactRef[] | undefined): boolean =>
@@ -29,7 +29,7 @@ describe('ai tool docx backend', () => {
{ type: 'paragraph', text: '结尾段落。' }
])
runtime.draftReviewService.beginRun({
runtime.fileReviewService.beginRun({
runId: runtime.runId,
sessionId: 'docx-review-order-session',
inputText: '追加解释和对照表'
@@ -61,16 +61,16 @@ describe('ai tool docx backend', () => {
]
})
const details = await runtime.draftReviewService.finalizeRunReview({
const details = await runtime.fileReviewService.finalizeRunReview({
runId: runtime.runId,
workspaceRootPath: runtime.workspaceRoot
})
const document = details[0]?.documents[0]
if (!document?.review_artifact_path) {
const review = details[0]?.review
if (!review?.draft_artifact_path) {
throw new Error('expected review artifact path')
}
const reviewBuffer = await fs.readFile(document.review_artifact_path)
const reviewBuffer = await fs.readFile(review.draft_artifact_path)
const reviewBundle = await loadDocxBundle(reviewBuffer)
const reviewOutline = buildBlockIndex(reviewBundle.documentDom).outline
.filter((block) => block.type !== 'table_cell')
@@ -41,7 +41,7 @@ describe('ai tool docx backend', () => {
}
])
runtime.draftReviewService.beginRun({
runtime.fileReviewService.beginRun({
runId: runtime.runId,
sessionId: 'docx-review-projection-session',
inputText: '补写解释和表格'
@@ -65,22 +65,21 @@ describe('ai tool docx backend', () => {
['平滑边缘,降低锯齿', '记住训练噪声,泛化变差']
]
})
const details = await runtime.draftReviewService.finalizeRunReview({
const details = await runtime.fileReviewService.finalizeRunReview({
runId: runtime.runId,
workspaceRootPath: runtime.workspaceRoot
})
const detail = details[0]
const document = detail?.documents[0]
if (!detail || !document?.review_artifact_path || !document.working_artifact_path) {
if (!detail || !detail.review.draft_artifact_path || !detail.review.working_artifact_path) {
throw new Error('expected generated review artifacts')
}
const reviewBuffer = await fs.readFile(document.review_artifact_path)
const reviewBuffer = await fs.readFile(detail.review.draft_artifact_path)
const reviewBundle = await loadDocxBundle(reviewBuffer)
const reviewXml = new XMLSerializer().serializeToString(reviewBundle.documentDom)
const table = descendantElements(reviewBundle.documentDom.documentElement, 'tbl')[0]
const workingExists = await fs
.access(document.working_artifact_path)
.access(detail.review.working_artifact_path)
.then(() => true)
.catch(() => false)
@@ -88,8 +87,8 @@ describe('ai tool docx backend', () => {
outline,
tableInsert,
reviewId: detail.review.id,
reviewArtifactPath: document.review_artifact_path,
workingArtifactPath: document.working_artifact_path,
reviewArtifactPath: detail.review.draft_artifact_path,
workingArtifactPath: detail.review.working_artifact_path,
reviewXml,
reviewHasNativeRevisions:
descendantElements(reviewBundle.documentDom.documentElement, 'ins').length > 0 ||
@@ -109,8 +108,8 @@ describe('ai tool docx backend', () => {
runtime.assertThat(
result.tableInsert.changedBlocks.length === 1 &&
result.workingExists &&
result.reviewArtifactPath.includes('-review-') &&
result.workingArtifactPath.includes('-working-'),
result.reviewArtifactPath.length > 0 &&
result.workingArtifactPath.length > 0,
{
name: 'review-run-emits-working-and-review-artifacts',
message: 'docx review finalize 后应同时产出 working artifact 与 review artifact。',
@@ -42,7 +42,7 @@ describe('ai tool docx backend', () => {
}
])
await runtime.seedDraftReview({
await runtime.seedFileReview({
sessionId: 'review-aware-session',
runId: runtime.runId,
title: 'Review aware docx seed',
@@ -23,7 +23,7 @@ describe('ai tool docx backend', () => {
{ type: 'paragraph', text: '待修改的原文。' }
])
runtime.draftReviewService.beginRun({
runtime.fileReviewService.beginRun({
runId: runtime.runId,
sessionId: 'commit-conflict-session',
inputText: '准备后制造冲突'
@@ -36,7 +36,7 @@ describe('ai tool docx backend', () => {
query: '待修改的原文。',
replace: '工作稿中的新版本。'
})
const details = await runtime.draftReviewService.finalizeRunReview({
const details = await runtime.fileReviewService.finalizeRunReview({
runId: runtime.runId,
workspaceRootPath: runtime.workspaceRoot
})
@@ -50,14 +50,14 @@ describe('ai tool docx backend', () => {
])
const externalSourceBuffer = await fs.readFile(`${runtime.workspaceRoot}/commit-conflict.docx`)
const validation = await runtime.draftReviewService.validateCommit({
const validation = await runtime.fileReviewService.validateCommit({
reviewId: detail.review.id
})
const commit = await runtime.draftReviewService.commitReview({
const commit = await runtime.fileReviewService.commitReview({
reviewId: detail.review.id,
overwrite: true
})
const conflictedDetail = await runtime.draftReviewStore.getReviewDetail(detail.review.id)
const conflictedDetail = await runtime.fileReviewStore.getReviewDetail(detail.review.id)
const sourceBuffer = await fs.readFile(`${runtime.workspaceRoot}/commit-conflict.docx`)
return {
@@ -44,7 +44,7 @@ describe('ai tool docx backend', () => {
}
])
const seeded = await runtime.seedDraftReview({
const seeded = await runtime.seedFileReview({
sessionId: 'commit-working-session',
runId: runtime.runId,
title: 'Commit uses committed artifact',
@@ -58,9 +58,9 @@ describe('ai tool docx backend', () => {
]
})
const detail = await runtime.draftReviewStore.getReviewDetail(seeded.reviewId)
const document = detail?.documents[0]
if (!document?.working_artifact_path || !document.committed_artifact_path) {
const detail = await runtime.fileReviewStore.getReviewDetail(seeded.reviewId)
const review = detail?.review
if (!review?.working_artifact_path || !review.committed_artifact_path) {
throw new Error('expected seeded working/committed artifact paths')
}
@@ -76,11 +76,11 @@ describe('ai tool docx backend', () => {
])
const workingSourceBuffer = await fs.readFile(`${runtime.workspaceRoot}/commit-working.docx`)
await fs.writeFile(document.working_artifact_path, workingSourceBuffer)
await fs.writeFile(review.working_artifact_path, workingSourceBuffer)
const committedSourceBuffer = await fs.readFile(`${runtime.workspaceRoot}/commit-committed.docx`)
await fs.writeFile(document.committed_artifact_path, committedSourceBuffer)
await fs.writeFile(review.committed_artifact_path, committedSourceBuffer)
const commit = await runtime.draftReviewService.commitReview({
const commit = await runtime.fileReviewService.commitReview({
reviewId: seeded.reviewId,
overwrite: true
})
@@ -26,7 +26,7 @@ describe('ai tool docx backend', () => {
])
const sourceBufferBefore = await fs.readFile(sourcePath)
runtime.draftReviewService.beginRun({
runtime.fileReviewService.beginRun({
runId: runtime.runId,
sessionId: 'discard-review-session',
inputText: '创建后再放弃'
@@ -39,17 +39,16 @@ describe('ai tool docx backend', () => {
query: '源文档原始内容。',
replace: '仅存在于工作稿中的内容。'
})
const details = await runtime.draftReviewService.finalizeRunReview({
const details = await runtime.fileReviewService.finalizeRunReview({
runId: runtime.runId,
workspaceRootPath: runtime.workspaceRoot
})
const detail = details[0]
const document = detail?.documents[0]
if (!detail || !document?.review_artifact_path || !document.working_artifact_path) {
if (!detail || !detail.review.draft_artifact_path || !detail.review.working_artifact_path) {
throw new Error('expected finalized review detail')
}
const discard = await runtime.draftReviewService.discardReview({
const discard = await runtime.fileReviewService.discardReview({
reviewId: detail.review.id
})
const sourceBufferAfter = await fs.readFile(sourcePath)
@@ -65,14 +64,14 @@ describe('ai tool docx backend', () => {
})
await freshHarness.release()
const reviewArtifactExists = await fs
.access(document.review_artifact_path)
.access(detail.review.draft_artifact_path)
.then(() => true)
.catch(() => false)
const workingArtifactExists = await fs
.access(document.working_artifact_path)
.access(detail.review.working_artifact_path)
.then(() => true)
.catch(() => false)
const discardedDetail = await runtime.draftReviewStore.getReviewDetail(detail.review.id)
const discardedDetail = await runtime.fileReviewStore.getReviewDetail(detail.review.id)
return {
changed,
@@ -21,7 +21,7 @@ describe('ai tool docx backend', () => {
runMode: 'single',
execute: async (runtime) => {
const workspaceSeed = await seedRiskReportWorkspace(runtime)
runtime.draftReviewService.beginRun({
runtime.fileReviewService.beginRun({
runId: runtime.runId,
sessionId: 'docx-risk-report-review-commit-session',
inputText: '补全风险周报模板并提交'
@@ -43,17 +43,16 @@ describe('ai tool docx backend', () => {
replace: '最终复核窗口由合规负责人 Alice 牵头。'
})
const details = await runtime.draftReviewService.finalizeRunReview({
const details = await runtime.fileReviewService.finalizeRunReview({
runId: runtime.runId,
workspaceRootPath: runtime.workspaceRoot
})
const detail = details[0]
const document = detail?.documents[0]
if (!detail || !document?.review_artifact_path) {
if (!detail || !detail.review.draft_artifact_path) {
throw new Error('expected generated review artifact for risk report')
}
const reviewRelativePath = path.relative(runtime.workspaceRoot, document.review_artifact_path)
const reviewRelativePath = path.relative(runtime.workspaceRoot, detail.review.draft_artifact_path)
const reviewRisk = await harness.invoke<DocxSearchResult>('docx_search', {
sourcePath: reviewRelativePath,
query: '待跟进'
@@ -71,10 +70,10 @@ describe('ai tool docx backend', () => {
query: 'OWNER_TODO'
})
const reviewXml = new XMLSerializer().serializeToString(
(await loadDocxBundle(await fs.readFile(document.review_artifact_path))).documentDom
(await loadDocxBundle(await fs.readFile(detail.review.draft_artifact_path))).documentDom
)
const commit = await runtime.draftReviewService.commitReview({
const commit = await runtime.fileReviewService.commitReview({
reviewId: detail.review.id,
overwrite: true
})
@@ -101,7 +100,7 @@ describe('ai tool docx backend', () => {
firstEdit,
secondEdit,
reviewId: detail.review.id,
reviewArtifactPath: document.review_artifact_path,
reviewArtifactPath: detail.review.draft_artifact_path,
reviewRisk,
reviewOwner,
reviewOldRiskPlaceholder,
+6 -6
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { createHumanGraphRunner } from '../../../src/main/services/ai/human/graph/humanGraph'
import { DraftReviewService } from '../../../src/main/services/ai/human/tools/docx/review/DraftReviewService'
import { FileReviewService } from '../../../src/main/services/ai/human/tools/docx/review/FileReviewService'
import { createAiRunContext } from '../../../src/main/services/ai/runtime'
import { runAiRuntimePreflight } from '../../../src/main/services/ai/runtimePreflight'
import type { AiApprovalDecision, AiApprovalRequest } from '../../../src/main/services/ai/types'
@@ -44,7 +44,7 @@ const buildApprovalResolver = (
export type RealAiDepsFactory = {
storageService: StorageService
embeddingService: EmbeddingService
draftReviewService: DraftReviewService
fileReviewService: FileReviewService
ensureEmbeddingReady: () => Promise<void>
createAiRunContext: (input?: CreateAiRunContextInput) => AiRunContext
createHumanTaskRunner: (input?: CreateAiRunContextInput) => ReturnType<typeof createHumanGraphRunner>
@@ -66,8 +66,8 @@ export const createRealAiDepsFactory = async (input: {
const embeddingService = new EmbeddingService()
const modelService = ModelService.getInstance()
const draftReviewService = new DraftReviewService(
storageService.draftReview,
const fileReviewService = new FileReviewService(
storageService.fileReview,
storageService.documents
)
@@ -120,7 +120,7 @@ export const createRealAiDepsFactory = async (input: {
documentsStore: storageService.documents,
embeddingService,
ensureEmbeddingReady: ensureReady,
draftReviewService,
fileReviewService,
isCancelled: contextInput.checkCancelledOverride || (() => false),
sendEvent: (event) => input.eventCollector.add(event),
recordLlmIo: (record) => input.eventCollector.addLlmIo(record),
@@ -169,7 +169,7 @@ export const createRealAiDepsFactory = async (input: {
return {
storageService,
embeddingService,
draftReviewService,
fileReviewService,
ensureEmbeddingReady: ensureReady,
createAiRunContext: createContext,
createHumanTaskRunner: (contextInput = {}) => {
+6 -6
View File
@@ -9,7 +9,7 @@ import type { FixtureBinding } from './test-types'
import { createRealAiDepsFactory } from './realAiDepsFactory'
import { seedKbFixture } from './seedKbFixture'
import { seedChatHistory } from './seedChatHistory'
import { seedDraftReview } from './seedDraftReview'
import { seedFileReview } from './seedFileReview'
import {
appendSeedKbDocuments,
appendSeedWorkspaceFile,
@@ -50,10 +50,10 @@ export const createRealAiHarness = async (input: {
storageService: depsFactory.storageService,
documentsStore: depsFactory.storageService.documents,
chatStore: depsFactory.storageService.chat,
draftReviewStore: depsFactory.storageService.draftReview,
fileReviewStore: depsFactory.storageService.fileReview,
llmStore: depsFactory.storageService.llm,
embeddingService: depsFactory.embeddingService,
draftReviewService: depsFactory.draftReviewService,
fileReviewService: depsFactory.fileReviewService,
recordEvent: input.recordEvent,
assertThat: (condition, assertion) => {
input.recordEvent({
@@ -101,10 +101,10 @@ export const createRealAiHarness = async (input: {
payload
})
},
seedDraftReview: async (payload) =>
await seedDraftReview({
seedFileReview: async (payload) =>
await seedFileReview({
workspaceRoot: input.workspaceRoot,
draftReviewStore: depsFactory.storageService.draftReview,
fileReviewStore: depsFactory.storageService.fileReview,
payload
}),
seedLlmConfig: async (config) => {
-89
View File
@@ -1,89 +0,0 @@
import { randomUUID } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { DraftReviewStore } from '../../../src/main/services/storage/domains/DraftReviewStore'
import { buildSourceVersion } from '../../../src/main/services/ai/human/tools/docx/review/utils'
import { buildDocxReviewArtifactPaths } from '../../../src/main/services/ai/human/tools/docx/review/viewProjection'
import type { SeedDraftReviewInput } from './test-types'
export const seedDraftReview = async (input: {
workspaceRoot: string
draftReviewStore: DraftReviewStore
payload: SeedDraftReviewInput
}): Promise<{ reviewId: string }> => {
const reviewId = input.payload.reviewId || randomUUID()
const createdAt = input.payload.createdAt || Date.now()
const updatedAt = input.payload.updatedAt || createdAt
const reviewArtifactsRoot = path.join(
input.workspaceRoot,
'.ncurator-test',
'draft-reviews',
reviewId
)
await fs.mkdir(reviewArtifactsRoot, { recursive: true })
const documents = await Promise.all(
input.payload.documents.map(async (document, index) => {
const documentId = `${reviewId}-doc-${index + 1}`
const sourceVersion = await buildSourceVersion(document.sourcePath)
const confirmSourcePath = document.confirmBasePath || document.sourcePath
const {
reviewArtifactPath: confirmArtifactPath,
workingArtifactPath: effectiveDraftArtifactPath,
committedArtifactPath
} =
buildDocxReviewArtifactPaths({
workspaceRootPath: input.workspaceRoot,
reviewId,
documentIndex: index,
sourceName: document.sourceName || path.basename(document.sourcePath)
})
await fs.mkdir(path.dirname(effectiveDraftArtifactPath), { recursive: true })
await fs.copyFile(confirmSourcePath, effectiveDraftArtifactPath)
await fs.copyFile(confirmSourcePath, confirmArtifactPath)
await fs.copyFile(confirmSourcePath, committedArtifactPath)
return {
id: documentId,
review_id: reviewId,
source_document_id: document.sourceDocumentId,
source_path: document.sourcePath,
source_name: document.sourceName || path.basename(document.sourcePath),
format: document.format || 'docx',
latest_draft_snapshot_id: document.latestDraftSnapshotId || `${documentId}-snapshot`,
review_artifact_path: confirmArtifactPath,
working_artifact_path: effectiveDraftArtifactPath,
committed_artifact_path: committedArtifactPath,
source_version: sourceVersion,
total_change_count: 0,
pending_change_count: 0,
accepted_change_count: 0,
rejected_change_count: 0,
created_at: createdAt,
updated_at: updatedAt
}
})
)
await input.draftReviewStore.saveReviewDetail({
review: {
id: reviewId,
session_id: input.payload.sessionId || 'test-session',
run_id: input.payload.runId || 'test-run',
status: input.payload.status || 'pending_confirm',
title: input.payload.title || 'Test Draft Review',
document_count: documents.length,
change_count: 0,
pending_change_count: 0,
accepted_change_count: 0,
rejected_change_count: 0,
created_at: createdAt,
updated_at: updatedAt
},
documents,
changes: [],
commit_jobs: []
})
return { reviewId }
}
+64
View File
@@ -0,0 +1,64 @@
import { randomUUID } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { FileReviewStore } from '../../../src/main/services/storage/domains/FileReviewStore'
import { buildSourceVersion } from '../../../src/main/services/ai/human/tools/docx/review/utils'
import { buildDocxReviewArtifactPaths } from '../../../src/main/services/ai/human/tools/docx/review/viewProjection'
import type { SeedFileReviewInput } from './test-types'
export const seedFileReview = async (input: {
workspaceRoot: string
fileReviewStore: FileReviewStore
payload: SeedFileReviewInput
}): Promise<{ reviewId: string }> => {
const reviewId = input.payload.reviewId || randomUUID()
const createdAt = input.payload.createdAt || Date.now()
const updatedAt = input.payload.updatedAt || createdAt
const document = input.payload.documents[0]
if (!document) {
throw new Error('seedFileReview requires one document payload')
}
const sourceVersion = await buildSourceVersion(document.sourcePath)
const confirmSourcePath = document.confirmBasePath || document.sourcePath
const { draftArtifactPath, workingArtifactPath, committedArtifactPath } =
buildDocxReviewArtifactPaths({
workspaceRootPath: input.workspaceRoot,
reviewId,
sourceName: document.sourceName || path.basename(document.sourcePath)
})
await fs.mkdir(path.dirname(workingArtifactPath), { recursive: true })
await fs.copyFile(confirmSourcePath, workingArtifactPath)
await fs.copyFile(confirmSourcePath, draftArtifactPath)
await fs.copyFile(confirmSourcePath, committedArtifactPath)
await input.fileReviewStore.saveReviewDetail({
review: {
id: reviewId,
session_id: input.payload.sessionId || 'test-session',
run_id: input.payload.runId || 'test-run',
status: input.payload.status || 'pending_confirm',
title: input.payload.title || 'Test File Review',
source_document_id: document.sourceDocumentId,
source_path: document.sourcePath,
source_name: document.sourceName || path.basename(document.sourcePath),
format: document.format || 'docx',
review_render_kind: 'docx_block',
latest_draft_snapshot_id: document.latestDraftSnapshotId || `${reviewId}-snapshot`,
draft_artifact_path: draftArtifactPath,
working_artifact_path: workingArtifactPath,
committed_artifact_path: committedArtifactPath,
source_version: sourceVersion,
change_count: 0,
pending_change_count: 0,
accepted_change_count: 0,
rejected_change_count: 0,
created_at: createdAt,
updated_at: updatedAt
},
changes: [],
commit_attempts: []
})
return { reviewId }
}
+10 -10
View File
@@ -4,18 +4,18 @@ import type {
AiRunContext
} from '../../../src/main/services/ai/types'
import type { HumanTaskRunner } from '../../../src/main/services/ai/types'
import type { DraftReviewService } from '../../../src/main/services/ai/human/tools/docx/review/DraftReviewService'
import type { FileReviewService } from '../../../src/main/services/ai/human/tools/docx/review/FileReviewService'
import type { ChatStore } from '../../../src/main/services/storage/domains/ChatStore'
import type { DocumentsStore } from '../../../src/main/services/storage/domains/DocumentsStore'
import type { DraftReviewStore } from '../../../src/main/services/storage/domains/DraftReviewStore'
import type { FileReviewStore } from '../../../src/main/services/storage/domains/FileReviewStore'
import type { LlmConfigStore } from '../../../src/main/services/storage/domains/LlmConfigStore'
import type { StorageService } from '../../../src/main/services/storage/StorageService'
import type { EmbeddingService } from '../../../src/main/services/vector/EmbeddingService'
import type {
ChatMessage,
ChatSessionMemory,
DraftReviewDocument,
DraftReviewStatus,
FileReview,
FileReviewStatus,
JsonObject,
JsonValue,
LLMConfig,
@@ -122,12 +122,12 @@ export type SeedChatHistoryInput = {
memory?: ChatSessionMemory
}
export type SeedDraftReviewInput = {
export type SeedFileReviewInput = {
reviewId?: string
sessionId?: string
runId?: string
title?: string
status?: DraftReviewStatus
status?: FileReviewStatus
createdAt?: number
updatedAt?: number
documents: readonly Array<{
@@ -135,7 +135,7 @@ export type SeedDraftReviewInput = {
confirmBasePath?: string
sourceName?: string
sourceDocumentId?: string
format?: DraftReviewDocument['format']
format?: FileReview['format']
latestDraftSnapshotId?: string
}>
}
@@ -165,10 +165,10 @@ export type BackendTestRuntime = {
storageService: StorageService
documentsStore: DocumentsStore
chatStore: ChatStore
draftReviewStore: DraftReviewStore
fileReviewStore: FileReviewStore
llmStore: LlmConfigStore
embeddingService: EmbeddingService
draftReviewService: DraftReviewService
fileReviewService: FileReviewService
recordEvent: (input: Omit<TestLogEvent, 'ts'>) => void
assertThat: (condition: boolean, input: { name: string; message: string; data?: JsonValue }) => void
getFixtureWorkspacePath: (sourcePath: string) => string
@@ -179,7 +179,7 @@ export type BackendTestRuntime = {
ensureEmbeddingReady: () => Promise<void>
seedKb: (input: readonly SeedKbDocumentInput[]) => Promise<void>
seedChatHistory: (input: SeedChatHistoryInput) => Promise<void>
seedDraftReview: (input: SeedDraftReviewInput) => Promise<{ reviewId: string }>
seedFileReview: (input: SeedFileReviewInput) => Promise<{ reviewId: string }>
seedLlmConfig: (config: LiveLlmConfig) => Promise<void>
}
+10 -11
View File
@@ -75,17 +75,15 @@ describe('ai unit mouth readyToDeliver guard', () => {
})
}).system
expect(prompt).toContain('<available_information>')
expect(prompt).toContain('<context_information>')
expect(prompt).toContain('<output_contract>')
expect(prompt).toContain('吸收当前任务在完整上下文下已经成立的信息')
expect(prompt).toContain('把用户诉求、当前事实、已有结果与可引用产物自然组织成回复')
expect(prompt).toContain('面向用户回答 task_goal')
expect(prompt).toContain('回答应基于当前整体状态自然组织')
expect(prompt).toContain('优先依据 your_notes 组织答案或交付内容')
expect(prompt).toContain('与更具体的证据记录存在张力')
expect(prompt).toContain('reference_scope')
expect(prompt).toContain('当前工作区、默认资料范围、显式选择的优先参考资料与当前缩圈状态')
expect(prompt).toContain('回答面向用户,但内容必须忠实于当前整体状态')
expect(prompt).toContain('优先吸收 reference_scope、your_notes、history、runtime_artifacts')
expect(prompt).toContain('只输出最终 markdown 正文')
expect(prompt).toContain('如果最终答案补充了不来自当前本地资料的信息')
expect(prompt).not.toContain('必须在结尾单独加一行“补充说明')
expect(prompt).toContain('如果需要回复不在上下文中的内容时')
expect(prompt).toContain('补充说明')
})
it('keeps mouth user-context blocks data-only', () => {
@@ -96,9 +94,10 @@ describe('ai unit mouth readyToDeliver guard', () => {
})
})
expect(result.system).toContain('task_goal:用户最初要你完成什么')
expect(result.system).toContain('当前理解:当前任务的最新长期理解')
expect(result.system).toContain('task_goal:用户的诉求')
expect(result.system).toContain('your_notes:当前持续工作的任务笔记')
expect(result.system).not.toContain('final_decision')
expect(result.user).toContain('<reference_scope>')
expect(result.user).not.toContain('这是当前交付模式。')
expect(result.user).not.toContain('这是用户这次原始诉求与限制。')
expect(result.user).toContain('<history>')
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { createInitialHumanState } from '../../../src/main/services/ai/human/state/schema'
import { renderHumanArtifactLines } from '../../../src/main/services/ai/human/shared/runtimeRenderers'
describe('ai unit runtime artifacts renderer', () => {
it('renders changed files in prompt mode', () => {
const state = createInitialHumanState({
taskId: 'task-runtime-1',
userInput: '整理文档'
})
state.execution.runtimeArtifacts = {
changed_files: [
{
file_name: 'sample.docx',
source_path: '/workspace/sample.docx',
format: 'docx',
change_count: 2,
summary: '补充了课堂说明',
change_highlights: ['补充交互设计说明', '新增总结表'],
draft_artifact_path: '/workspace/.ncurator/file-reviews/review-1/draft-sample.docx'
}
]
}
const rendered = renderHumanArtifactLines(state, 'prompt')
expect(rendered).toContain('文件:sample.docx')
expect(rendered).toContain('改动数:2')
expect(rendered).toContain('补充交互设计说明')
expect(rendered).toContain('待确认稿:/workspace/.ncurator/file-reviews/review-1/draft-sample.docx')
})
it('renders empty fallback when no changed files exist', () => {
const state = createInitialHumanState({
taskId: 'task-runtime-2',
userInput: '整理文档'
})
const rendered = renderHumanArtifactLines(state, 'prompt')
expect(rendered).toContain('本次运行未形成新的文件改动结果。')
expect(rendered).not.toContain('draft review')
})
})