diff --git a/muse-studio/src/api/mocks/handlers/ai.ts b/muse-studio/src/api/mocks/handlers/ai.ts index d89f819f..a13de62d 100644 --- a/muse-studio/src/api/mocks/handlers/ai.ts +++ b/muse-studio/src/api/mocks/handlers/ai.ts @@ -21,6 +21,7 @@ type CreateAiTaskRequestBody = { userInstruction?: string; }; commandId?: string; + workId?: string; }; type SlotPrecheckRequest = { @@ -285,6 +286,11 @@ export const aiHandlers = [ if (!body.commandId) { return HttpResponse.json({ code: 40010, msg: '缺少 commandId', data: null }, { status: 400 }); } + // 防假绿:workId 是服务端的上下文与权限边界,真后端缺失时按权限拒绝。 + // 此前 mock 只校验 commandId,前端不下传上下文也能拿到 taskId(假绿);这里补上 workId 必填校验复现真实边界。 + if (!body.workId) { + return HttpResponse.json({ code: 40011, msg: '缺少 workId', data: null }, { status: 400 }); + } const taskId = crypto.randomUUID(); mockAiTasks.set(taskId, { diff --git a/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx b/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx index f4194f38..dfee9591 100644 --- a/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx +++ b/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx @@ -44,7 +44,15 @@ describe('AIPanel task stream contract', () => { ); const onCandidateGenerated = vi.fn(); - render(React.createElement(AIPanel, { onCandidateGenerated })); + // 传入真实上下文引用:证明面板把 workId/blockId/agentSlotKey 一并提交给后端,而非残缺 payload。 + render( + React.createElement(AIPanel, { + onCandidateGenerated, + workId: 'work-abc', + blockId: 'block-xyz', + agentSlotKey: 'writing.continuation', + }) + ); fireEvent.change(screen.getByPlaceholderText(/在此处输入创作描述/), { target: { value: '秘密提示词' }, @@ -55,6 +63,7 @@ describe('AIPanel task stream contract', () => { await waitFor(() => expect(onCandidateGenerated).toHaveBeenCalledWith('星海', '6001')); expect(createRequests).toHaveLength(1); expect(createRequests[0]?.url).not.toContain('秘密提示词'); + // 收紧契约断言:除 intent/contextScope,还须证明上下文引用真传了、传对了(防"前端不下传上下文仍假绿")。 expect(createRequests[0]?.body).toMatchObject({ intent: { kind: 'continuation', @@ -62,6 +71,9 @@ describe('AIPanel task stream contract', () => { outputTarget: 'suggestion', }, contextScope: 'work', + workId: 'work-abc', + blockId: 'block-xyz', + agentSlotKey: 'writing.continuation', }); expect(streamRequests).toEqual(['http://localhost:3000/app-api/muse/ai/tasks/task-123/stream']); }); diff --git a/muse-studio/src/features/editor/components/AIPanel.tsx b/muse-studio/src/features/editor/components/AIPanel.tsx index 3a097d1c..85dd7f7b 100644 --- a/muse-studio/src/features/editor/components/AIPanel.tsx +++ b/muse-studio/src/features/editor/components/AIPanel.tsx @@ -3,6 +3,9 @@ import { connectAIStream } from '@/lib/sse'; import { api } from '@/api/client'; import type { CreateAiTaskRequest, CreateAiTaskResult } from '@/types/openapi'; +/** 续写场景默认绑定的智能体槽位 key(由 Agent BC 解析为具体 Agent 版本)。 */ +const DEFAULT_AGENT_SLOT_KEY = 'writing.continuation'; + /** AI 面板 Props 定义 */ interface AIPanelProps { /** @@ -11,13 +14,27 @@ interface AIPanelProps { * @param suggestionId 后端 done 事件回传的候选 ID;用户采纳写入 Canonical 时必须携带,缺失时为 null(无法采纳,仅可预览) */ onCandidateGenerated: (content: string, suggestionId: string | null) => void; + /** 目标作品 ID:服务端据此作为上下文与权限边界,必传(缺失时后端按权限拒绝)。 */ + workId: string; + /** 目标 Block 引用:续写/正文生成场景所需,服务端校验其归属 workId。 */ + blockId?: string; + /** 目标章节引用:服务端校验其归属 workId。 */ + chapterId?: string; + /** 作品内智能体槽位 key;未传时默认续写槽位 writing.continuation。 */ + agentSlotKey?: string; } /** * AI 辅助创作面板 * 发起流式续写或优化指令,展示 Token 级增量打字机视口,支持安全中止和网络重试 */ -export default function AIPanel({ onCandidateGenerated }: AIPanelProps) { +export default function AIPanel({ + onCandidateGenerated, + workId, + blockId, + chapterId, + agentSlotKey, +}: AIPanelProps) { const [prompt, setPrompt] = React.useState(''); const [isGenerating, setIsGenerating] = React.useState(false); const [streamContent, setStreamContent] = React.useState(''); @@ -47,6 +64,14 @@ export default function AIPanel({ onCandidateGenerated }: AIPanelProps) { userInstruction: prompt.trim(), outputTarget: 'suggestion', }, + // 下传真实上下文引用:服务端以 workId 作为权限边界,并校验 block/chapter 归属该作品。 + // 此前只传 commandId/intent/contextScope,残缺上下文被宽松 mock 掩盖;这里补全以贴合真后端契约。 + workId, + // blockId/chapterId 仅在有值时带键(exactOptionalPropertyTypes 下不允许显式赋 undefined)。 + ...(blockId ? { blockId } : {}), + ...(chapterId ? { chapterId } : {}), + // 槽位 key 缺省回退到续写槽位,确保后端总能解析出具体 Agent 版本。 + agentSlotKey: agentSlotKey ?? DEFAULT_AGENT_SLOT_KEY, contextScope: 'work', }; const task = await api.post('/ai/tasks', taskRequest); diff --git a/muse-studio/src/pages/EditorPage.tsx b/muse-studio/src/pages/EditorPage.tsx index b8c9a388..6f4a5672 100644 --- a/muse-studio/src/pages/EditorPage.tsx +++ b/muse-studio/src/pages/EditorPage.tsx @@ -134,7 +134,12 @@ export default function EditorPage() { onReject={handleRejectDiff} /> ) : ( - + )} diff --git a/muse-studio/src/pages/WorkspacePage.tsx b/muse-studio/src/pages/WorkspacePage.tsx index e57b42b5..38ad1b29 100644 --- a/muse-studio/src/pages/WorkspacePage.tsx +++ b/muse-studio/src/pages/WorkspacePage.tsx @@ -308,7 +308,14 @@ export default function WorkspacePage() { isSubmitting={acceptSuggestion.isPending} /> ) : ( - + ) ) : (
请在左侧大纲选择章节,以使用 AI 创作助手。