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:
parent
8e6e320cc1
commit
df063cbacd
@ -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, {
|
||||||
|
|||||||
@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user