feat(studio): AI 生成提交补全上下文引用(workId/blockId/agentSlotKey),消除前端假绿

AIPanel 此前只传 commandId/intent/contextScope,残缺上下文被宽松 mock 掩盖(假绿)。补全:Props 接收 + payload 下传 workId(必填)/blockId/chapterId/agentSlotKey,WorkspacePage 透传真实 workId(useParams)+activeBlock.id+activeChapterId,agentSlotKey 续写默认 writing.continuation,接通槽位绑定↔生成断层。防假绿:mock /ai/tasks 收紧 workId 必填(40011);contract test 断言 body 含 workId/blockId/agentSlotKey(反向验证:删 workId 下传则测试变红)。范围:未做 agentOverrideRef UI/AgentPage 硬编码/EditorPage 重构。验证:全量单测 101/101 + tsc -b --noEmit exit 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-24 09:01:52 -07:00
parent 8e6e320cc1
commit df063cbacd
5 changed files with 59 additions and 4 deletions

View File

@ -21,6 +21,7 @@ type CreateAiTaskRequestBody = {
userInstruction?: string; userInstruction?: string;
}; };
commandId?: string; commandId?: string;
workId?: string;
}; };
type SlotPrecheckRequest = { type SlotPrecheckRequest = {
@ -285,6 +286,11 @@ export const aiHandlers = [
if (!body.commandId) { if (!body.commandId) {
return HttpResponse.json({ code: 40010, msg: '缺少 commandId', data: null }, { status: 400 }); 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(); const taskId = crypto.randomUUID();
mockAiTasks.set(taskId, { mockAiTasks.set(taskId, {

View File

@ -44,7 +44,15 @@ describe('AIPanel task stream contract', () => {
); );
const onCandidateGenerated = vi.fn(); 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(/在此处输入创作描述/), { fireEvent.change(screen.getByPlaceholderText(/在此处输入创作描述/), {
target: { value: '秘密提示词' }, target: { value: '秘密提示词' },
@ -55,6 +63,7 @@ describe('AIPanel task stream contract', () => {
await waitFor(() => expect(onCandidateGenerated).toHaveBeenCalledWith('星海', '6001')); await waitFor(() => expect(onCandidateGenerated).toHaveBeenCalledWith('星海', '6001'));
expect(createRequests).toHaveLength(1); expect(createRequests).toHaveLength(1);
expect(createRequests[0]?.url).not.toContain('秘密提示词'); expect(createRequests[0]?.url).not.toContain('秘密提示词');
// 收紧契约断言:除 intent/contextScope还须证明上下文引用真传了、传对了防"前端不下传上下文仍假绿")。
expect(createRequests[0]?.body).toMatchObject({ expect(createRequests[0]?.body).toMatchObject({
intent: { intent: {
kind: 'continuation', kind: 'continuation',
@ -62,6 +71,9 @@ describe('AIPanel task stream contract', () => {
outputTarget: 'suggestion', outputTarget: 'suggestion',
}, },
contextScope: 'work', 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']); expect(streamRequests).toEqual(['http://localhost:3000/app-api/muse/ai/tasks/task-123/stream']);
}); });

View File

@ -3,6 +3,9 @@ import { connectAIStream } from '@/lib/sse';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { CreateAiTaskRequest, CreateAiTaskResult } from '@/types/openapi'; import type { CreateAiTaskRequest, CreateAiTaskResult } from '@/types/openapi';
/** 续写场景默认绑定的智能体槽位 key由 Agent BC 解析为具体 Agent 版本)。 */
const DEFAULT_AGENT_SLOT_KEY = 'writing.continuation';
/** AI 面板 Props 定义 */ /** AI 面板 Props 定义 */
interface AIPanelProps { interface AIPanelProps {
/** /**
@ -11,13 +14,27 @@ interface AIPanelProps {
* @param suggestionId done ID Canonical null * @param suggestionId done ID Canonical null
*/ */
onCandidateGenerated: (content: string, suggestionId: string | null) => void; onCandidateGenerated: (content: string, suggestionId: string | null) => void;
/** 目标作品 ID服务端据此作为上下文与权限边界必传缺失时后端按权限拒绝。 */
workId: string;
/** 目标 Block 引用:续写/正文生成场景所需,服务端校验其归属 workId。 */
blockId?: string;
/** 目标章节引用:服务端校验其归属 workId。 */
chapterId?: string;
/** 作品内智能体槽位 key未传时默认续写槽位 writing.continuation。 */
agentSlotKey?: string;
} }
/** /**
* AI * AI
* Token * Token
*/ */
export default function AIPanel({ onCandidateGenerated }: AIPanelProps) { export default function AIPanel({
onCandidateGenerated,
workId,
blockId,
chapterId,
agentSlotKey,
}: AIPanelProps) {
const [prompt, setPrompt] = React.useState(''); const [prompt, setPrompt] = React.useState('');
const [isGenerating, setIsGenerating] = React.useState(false); const [isGenerating, setIsGenerating] = React.useState(false);
const [streamContent, setStreamContent] = React.useState(''); const [streamContent, setStreamContent] = React.useState('');
@ -47,6 +64,14 @@ export default function AIPanel({ onCandidateGenerated }: AIPanelProps) {
userInstruction: prompt.trim(), userInstruction: prompt.trim(),
outputTarget: 'suggestion', 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', contextScope: 'work',
}; };
const task = await api.post<CreateAiTaskResult>('/ai/tasks', taskRequest); const task = await api.post<CreateAiTaskResult>('/ai/tasks', taskRequest);

View File

@ -134,7 +134,12 @@ export default function EditorPage() {
onReject={handleRejectDiff} onReject={handleRejectDiff}
/> />
) : ( ) : (
<AIPanel onCandidateGenerated={handleCandidateGenerated} /> <AIPanel
onCandidateGenerated={handleCandidateGenerated}
workId="demo-work"
blockId={`b-${selectedChapterId}`}
chapterId={selectedChapterId}
/>
)} )}
</div> </div>
</div> </div>

View File

@ -308,7 +308,14 @@ export default function WorkspacePage() {
isSubmitting={acceptSuggestion.isPending} isSubmitting={acceptSuggestion.isPending}
/> />
) : ( ) : (
<AIPanel onCandidateGenerated={handleCandidateGenerated} /> <AIPanel
onCandidateGenerated={handleCandidateGenerated}
workId={workId}
blockId={activeBlock.id}
// chapterId 仅在有值时传exactOptionalPropertyTypes 下可选 prop 不接受显式 undefined
{...(activeChapterId ? { chapterId: activeChapterId } : {})}
agentSlotKey="writing.continuation"
/>
) )
) : ( ) : (
<div className="p-6 text-sm text-slate-400">使 AI </div> <div className="p-6 text-sm text-slate-400">使 AI </div>