diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index ef2bf483..16f4bcbe 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -256,10 +256,10 @@ #### WB-COMPOSER-010 附件保存与传递规则 -- 系统行为:用户通过 `+` 或粘贴添加附件时,附件统一保存到当前工作区的 `/.ncurator/assets//`;`assetBucketId` 是当前输入草稿生成的 UUID,和会话 ID 解耦,添加附件不会提前创建新会话。发送给 AI 的附件上下文只保留顺序、名称和路径,不再自动做图片识别、文件解析或内容摘要注入。 +- 系统行为:用户通过 `+` 或粘贴添加附件时,附件统一保存到当前工作区的 `/.ncurator/runtime/assets//`;`assetBucketId` 是当前输入草稿生成的 UUID,和会话 ID 解耦,添加附件不会提前创建新会话。发送给 AI 的附件上下文只保留顺序、名称和路径,不再自动做图片识别、文件解析或内容摘要注入。 - 验收: - `WB-COMPOSER-010-AC1` 未绑定可用工作区时,不允许添加附件,并提示先选择工作区或完成目录绑定。 - - `WB-COMPOSER-010-AC2` 已绑定工作区后,`+` 和粘贴添加的附件都会保存到 `/.ncurator/assets//`,且不会提前创建真实会话。 + - `WB-COMPOSER-010-AC2` 已绑定工作区后,`+` 和粘贴添加的附件都会保存到 `/.ncurator/runtime/assets//`,且不会提前创建真实会话。 - `WB-COMPOSER-010-AC3` 当前附件支持范围只有图片、`docx`、`md`、`json`;其他格式提示不支持且不加入附件列表。 - `WB-COMPOSER-010-AC4` 发送给 AI 的附件上下文只包含顺序、名称和路径,不包含发送前预解析摘要。 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index b521ccce..4cfba000 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -44,7 +44,7 @@ - docx apply case 需要真实 docx 输入、真实 AI 编辑、真实写回记录、真实预览和真实回退。 - `browser-use-task-panel` 需要真实 AI run 触发 `browser_use` 并驱动内置浏览器完成本地网页任务;当前已使用 DashScope / openai-compatible / `deepseek-v4-flash` 覆盖 Stagehand 官方 provider 与 `StagehandLangChainClient` 路径,不能用 fake browser 替代。 - `web_search` 与 `web_crawl` 需要分开覆盖:搜索 case 只验证候选网页返回;读取 case 必须验证 AI 继续读取网页正文后再回答。 -- Workbench 附件链路当前固定保存到 `/.ncurator/assets//`,`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。 +- Workbench 附件链路当前固定保存到 `/.ncurator/runtime/assets//`,`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。 ## 3. Suite 设计 @@ -147,7 +147,7 @@ tests/case/workbench/ #### `composer-attachments-context` - 测试类型:`e2e` -- What:验证 `+` 与粘贴添加附件时,附件会保存到工作区 `.ncurator/assets//`,不会提前创建真实会话,并保持图片预览、文件打开、删除、普通文本粘贴和格式提示行为。 +- What:验证 `+` 与粘贴添加附件时,附件会保存到工作区 `.ncurator/runtime/assets//`,不会提前创建真实会话,并保持图片预览、文件打开、删除、普通文本粘贴和格式提示行为。 - Why:附件链路已改为“统一落盘到工作区 + 只把路径交给 AI”,必须用回归测试守住真实保存路径和交互行为。 - How:创建真实工作区,通过真实粘贴图片和 `md` 文件触发产品附件保存逻辑;另外通过粘贴覆盖不支持格式和大文件 warning。 - Oracle:真实粘贴会触发附件保存;添加附件后没有真实会话;图片预览可打开;文件副标题显示 `MD`;不支持格式只提示不加入;大文件只提示不拦截;普通文本粘贴仍正常,对齐 `WB-COMPOSER-010-AC2`、`WB-COMPOSER-010-AC3`。 diff --git a/src/main/services/ai/human/tools/imageRecognition/toolSpecs.ts b/src/main/services/ai/human/tools/imageRecognition/toolSpecs.ts index 2cbd7111..e7cceed7 100644 --- a/src/main/services/ai/human/tools/imageRecognition/toolSpecs.ts +++ b/src/main/services/ai/human/tools/imageRecognition/toolSpecs.ts @@ -35,7 +35,7 @@ export const LLM_RECOGNIZE_TOOL_SPEC: HumanToolSpec< { goal: '识别截图里主要展示了什么', args: { - path: '/workspace/.ncurator/assets/session-1/screen.png', + path: '/workspace/.ncurator/runtime/assets/session-1/screen.png', instruction: '请概括这张截图的主要内容,并指出用户接下来最可能要做的操作。' } } diff --git a/src/main/services/attachments/AttachmentService.ts b/src/main/services/attachments/AttachmentService.ts index 132e2245..4c68470d 100644 --- a/src/main/services/attachments/AttachmentService.ts +++ b/src/main/services/attachments/AttachmentService.ts @@ -3,7 +3,8 @@ import path from 'path' import { v4 as uuidv4 } from 'uuid' import type { ChatAttachment } from '../../../shared/types' -const WORKSPACE_RUNTIME_DIR = '.ncurator' +const WORKSPACE_NCURATOR_DIR = '.ncurator' +const WORKSPACE_RUNTIME_DIR = 'runtime' const WORKSPACE_ASSETS_DIR = 'assets' const WORKSPACE_SCRIPTS_DIR = 'scripts' @@ -84,10 +85,16 @@ const isSupportedAttachment = (input: { return SUPPORTED_FILE_EXTENSIONS.has(ext) } -const getWorkspaceAssetsDir = (workspaceRootPath: string, assetBucketId: string): string => +const getWorkspaceRuntimeDir = (workspaceRootPath: string): string => path.join( path.resolve(String(workspaceRootPath || '').trim()), - WORKSPACE_RUNTIME_DIR, + WORKSPACE_NCURATOR_DIR, + WORKSPACE_RUNTIME_DIR + ) + +const getWorkspaceAssetsDir = (workspaceRootPath: string, assetBucketId: string): string => + path.join( + getWorkspaceRuntimeDir(workspaceRootPath), WORKSPACE_ASSETS_DIR, String(assetBucketId || '').trim() ) @@ -103,7 +110,7 @@ const ensureWorkspaceAttachmentDirs = async ( const assetDir = getWorkspaceAssetsDir(rootPath, assetBucketId) await Promise.all([ fs.promises.mkdir(assetDir, { recursive: true }), - fs.promises.mkdir(path.join(rootPath, WORKSPACE_RUNTIME_DIR, WORKSPACE_SCRIPTS_DIR), { + fs.promises.mkdir(path.join(getWorkspaceRuntimeDir(rootPath), WORKSPACE_SCRIPTS_DIR), { recursive: true }) ]) diff --git a/tests/case/unit/ai/ncurator-ref-codec.test.ts b/tests/case/unit/ai/ncurator-ref-codec.test.ts index 2e731841..93461ec0 100644 --- a/tests/case/unit/ai/ncurator-ref-codec.test.ts +++ b/tests/case/unit/ai/ncurator-ref-codec.test.ts @@ -34,7 +34,7 @@ describe('ai unit ncurator ref codec', () => { { name: 'windows path with dot-prefixed hidden dir (the reported bug)', label: '交互式探究学习案例:产业转移.docx', - path: 'D:\\1-work-space\\test\\workspace\\.ncurator\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\交互式探究学习案例:产业转移.docx', + path: 'D:\\1-work-space\\test\\workspace\\.ncurator\\runtime\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\交互式探究学习案例:产业转移.docx', resourceType: 'file' }, { @@ -146,7 +146,7 @@ describe('ai unit ncurator ref codec', () => { it('parses raw windows paths emitted without canonical encoding', () => { const rawPath = - 'D:\\1-work-space\\test\\workspace\\.ncurator\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\交互式探究学习案例:产业转移.docx' + 'D:\\1-work-space\\test\\workspace\\.ncurator\\runtime\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\交互式探究学习案例:产业转移.docx' const href = `ncurator://ref?kind=reference&resourceType=file&label=交互式探究学习案例:产业转移.docx&path=${rawPath}` expect(parseNcuratorRefHref(href)).toEqual({ @@ -160,12 +160,12 @@ describe('ai unit ncurator ref codec', () => { it('normalizes raw markdown links before CommonMark can consume path backslashes', () => { const rawPath = - 'D:\\1-work-space\\test\\workspace\\.ncurator\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\交互式探究学习案例:产业转移.docx' + 'D:\\1-work-space\\test\\workspace\\.ncurator\\runtime\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\交互式探究学习案例:产业转移.docx' const markdown = `[交互式探究学习案例:产业转移.docx](ncurator://ref?kind=reference&resourceType=file&label=交互式探究学习案例:产业转移.docx&path=${rawPath})` const normalized = normalizeNcuratorRefMarkdownLinks(markdown) const href = extractHref(normalized) - expect(href).toContain('%5C.ncurator%5Cassets') + expect(href).toContain('%5C.ncurator%5Cruntime%5Cassets') expect(commonmarkUnescapeDestination(href)).toBe(href) expect(parseNcuratorRefHref(href)).toEqual({ type: 'reference', diff --git a/tests/case/unit/workbench/attachment-service.test.ts b/tests/case/unit/workbench/attachment-service.test.ts new file mode 100644 index 00000000..5fceea44 --- /dev/null +++ b/tests/case/unit/workbench/attachment-service.test.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AttachmentService } from '../../../../src/main/services/attachments/AttachmentService' + +describe('attachment service runtime directories', () => { + let workspaceRootPath = '' + let sourceRootPath = '' + let service: AttachmentService + + beforeEach(async () => { + workspaceRootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ncurator-attachment-workspace-')) + sourceRootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ncurator-attachment-source-')) + service = new AttachmentService() + }) + + afterEach(async () => { + await Promise.all( + [workspaceRootPath, sourceRootPath] + .filter(Boolean) + .map((directoryPath) => fs.rm(directoryPath, { recursive: true, force: true })) + ) + }) + + it('saves attachments under .ncurator/runtime and creates the scripts directory', async () => { + const attachment = await service.saveAttachment({ + workspaceRootPath, + assetBucketId: 'bucket-1', + name: 'notes.md', + bytes: Buffer.from('# runtime attachment') + }) + + const expectedPath = path.join( + workspaceRootPath, + '.ncurator', + 'runtime', + 'assets', + 'bucket-1', + 'notes.md' + ) + expect(attachment.path).toBe(expectedPath) + await expect(fs.readFile(expectedPath, 'utf8')).resolves.toBe('# runtime attachment') + expect( + (await fs.stat(path.join(workspaceRootPath, '.ncurator', 'runtime', 'scripts'))).isDirectory() + ).toBe(true) + await expect(fs.stat(path.join(workspaceRootPath, '.ncurator', 'assets'))).rejects.toThrow() + await expect(fs.stat(path.join(workspaceRootPath, '.ncurator', 'scripts'))).rejects.toThrow() + }) + + it('imports attachments into the runtime bucket and keeps names unique', async () => { + const sourcePath = path.join(sourceRootPath, 'reference.json') + await fs.writeFile(sourcePath, '{"ready":true}') + + const first = await service.importAttachmentFromPath({ + workspaceRootPath, + assetBucketId: 'bucket-2', + sourcePath + }) + const second = await service.importAttachmentFromPath({ + workspaceRootPath, + assetBucketId: 'bucket-2', + sourcePath + }) + + const bucketPath = path.join(workspaceRootPath, '.ncurator', 'runtime', 'assets', 'bucket-2') + expect(first.path).toBe(path.join(bucketPath, 'reference.json')) + expect(second.path).toBe(path.join(bucketPath, 'reference-1.json')) + await expect(fs.readFile(second.path, 'utf8')).resolves.toBe('{"ready":true}') + }) +}) diff --git a/tests/case/unit/workbench/markdown-renderer-reference-link.test.ts b/tests/case/unit/workbench/markdown-renderer-reference-link.test.ts index 89120ab5..4f9c1176 100644 --- a/tests/case/unit/workbench/markdown-renderer-reference-link.test.ts +++ b/tests/case/unit/workbench/markdown-renderer-reference-link.test.ts @@ -35,12 +35,12 @@ describe('workbench markdown renderer reference links', () => { }) it('opens raw windows ncurator file references with the original path', async () => { - const { default: MarkdownRenderer } = await import( - '../../../../src/renderer/src/components/MarkdownRenderer' - ) + const { default: MarkdownRenderer } = + await import('../../../../src/renderer/src/components/MarkdownRenderer') const onOpenReference = vi.fn() - const fileName = '\u4ea4\u4e92\u5f0f\u63a2\u7a76\u5b66\u4e60\u6848\u4f8b\uff1a\u4ea7\u4e1a\u8f6c\u79fb.docx' - const filePath = `D:\\1-work-space\\test\\workspace\\.ncurator\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\${fileName}` + const fileName = + '\u4ea4\u4e92\u5f0f\u63a2\u7a76\u5b66\u4e60\u6848\u4f8b\uff1a\u4ea7\u4e1a\u8f6c\u79fb.docx' + const filePath = `D:\\1-work-space\\test\\workspace\\.ncurator\\runtime\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\${fileName}` const content = `[${fileName}](ncurator://ref?kind=reference&resourceType=file&label=${fileName}&path=${filePath})` await act(async () => {