From b38935f6d8413e30a60b5de9597c282b0f2e8498 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 17:57:35 +0800 Subject: [PATCH 1/3] fix(quick-start-agent): present editable prompt proposals Internal planner output was leaking implementation details into the user conversation. Replace assumptions with a user-facing summary and hold generation until the proposed prompt is filled and edited. Keep protocol errors sanitized while preserving explicit user control before generation. --- .../src/features/quick-start-agent/planner.ts | 30 +++-- .../src/features/quick-start-agent/react.ts | 16 ++- .../src/features/quick-start-agent/runtime.ts | 42 +++--- frontend/src/pages/quick-start/index.tsx | 126 ++++++++++++------ 4 files changed, 133 insertions(+), 81 deletions(-) diff --git a/frontend/src/features/quick-start-agent/planner.ts b/frontend/src/features/quick-start-agent/planner.ts index fa78aad8..a2c0369a 100644 --- a/frontend/src/features/quick-start-agent/planner.ts +++ b/frontend/src/features/quick-start-agent/planner.ts @@ -39,7 +39,7 @@ export interface CreateAiSdkQuickStartPlannerOptions { interface StartCharacterGenerationToolInput { optimizedPrompt: string - assumptions: string[] + optimizationSummary: string } const startCharacterGenerationTool = tool({ @@ -56,14 +56,15 @@ const startCharacterGenerationTool = tool({ maxLength: 4_000, description: '实际提交给角色母版生成器的完整单角色全身提示词。', }, - assumptions: { - type: 'array', - maxItems: 6, - items: { type: 'string', minLength: 1, maxLength: 200 }, - description: '为了立即生成而采用、且需要向用户明确展示的默认假设。', + optimizationSummary: { + type: 'string', + minLength: 1, + maxLength: 600, + description: + '面向用户的一到两句优化说明,只说明会保留、补充或移除什么;不得暴露推理步骤、默认假设、Tool 或内部状态。', }, }, - required: ['optimizedPrompt', 'assumptions'], + required: ['optimizedPrompt', 'optimizationSummary'], }, { validate(value) { @@ -71,7 +72,10 @@ const startCharacterGenerationTool = tool({ const plan = parseCharacterGenerationPlan(value) return { success: true, - value: { optimizedPrompt: plan.optimizedPrompt, assumptions: [...plan.assumptions] }, + value: { + optimizedPrompt: plan.optimizedPrompt, + optimizationSummary: plan.optimizationSummary, + }, } } catch (error) { return { @@ -86,23 +90,23 @@ const startCharacterGenerationTool = tool({ export function quickStartPlannerInstructions(clarificationUsed: boolean): string { const clarificationRule = clarificationUsed - ? '追问额度已经用完。除非仍存在硬冲突,否则不得再提问题;请采用明确的默认假设并调用 Tool。' + ? '追问额度已经用完。除非仍存在硬冲突,否则不得再提问题;请把必要补全直接写入 optimizedPrompt,并用面向用户的 optimizationSummary 简短说明,然后调用 Tool。' : '追问额度尚未使用。只有缺少会实质改变角色母版的关键信息时,才最多追问一个问题。' return `你是 Windup Quick Start 的轻量 Planner。用户已经通过“生成角色”主操作授予本页面会话一次角色母版生成权限。你只判断信息是否足够,并且最多调用一次 ${START_CHARACTER_GENERATION_TOOL};你不参与生成后的任何流程。 -当前能力只生成一个角色的角色母版:单角色、完整身体、清楚轮廓,适合后续动作生成。保留用户明确给出的身份、外观、服装、气质和美术风格,把口语整理成可直接生成的 optimizedPrompt。用户描述的动态动作不要写进角色母版 Prompt;需要时在 assumptions 中明确“动作将在角色母版确认后处理”。 +当前能力只生成一个角色的角色母版:单角色、完整身体、清楚轮廓,适合后续动作生成。保留用户明确给出的身份、外观、服装、气质和美术风格,把口语整理成可直接生成的 optimizedPrompt。用户描述的动态动作不要写进角色母版 Prompt。 决策规则: 1. 信息足够时立即调用唯一 Tool,不要再次确认。 -2. 用户在语义上明确要求“跳过、不用问、直接生成”时,基于已有信息与可见默认假设立即调用 Tool。 +2. 用户在语义上明确要求“跳过、不用问、直接生成”时,基于已有信息完成必要补全,把补全结果直接写入 optimizedPrompt,并在 optimizationSummary 中用正常对话说明,然后立即调用 Tool。 3. 用户明确表示“不要生成、只润色、先讨论”等否定意图时不得调用 Tool。引用、假设或否定语境也不得被误判;不得依赖关键词匹配,必须理解整句语义。 4. 信息不足且仍有追问额度时,只问一个最关键、最容易回答的问题,不得列问卷。 5. 输入有明显自相矛盾、违反内容政策,或超出当前单角色母版能力的硬冲突时,简短说明需要修改的具体内容,不调用 Tool。Planner 的提醒只是交互提示,最终安全、配额与计费仍由生成后端负责。 -6. 调用 Tool 时,optimizedPrompt 必须是最终实际使用的提示词;assumptions 只列确实采用的默认值,可以为空。不得产生第二个 Tool Call。 +6. 调用 Tool 时,optimizedPrompt 是交给用户检查和编辑的完整提示词;optimizationSummary 用一到两句正常对话说明你保留、补充或移除了什么。不得输出思维过程、逐步推理、默认假设清单、Tool 名称、调用计划或内部状态。不得产生第二个 Tool Call。 ${clarificationRule} -不调用 Tool 时,只输出一条简短的中文追问或修改提醒。调用 Tool 后不再输出后续计划。` +不调用 Tool 时,只输出一条简短的中文追问或修改提醒。调用 Tool 后不再输出额外文字,宿主会把 optimizationSummary 和 optimizedPrompt 作为用户可编辑的提案展示。` } export function createAiSdkQuickStartPlanner({ diff --git a/frontend/src/features/quick-start-agent/react.ts b/frontend/src/features/quick-start-agent/react.ts index 0b411822..daef5450 100644 --- a/frontend/src/features/quick-start-agent/react.ts +++ b/frontend/src/features/quick-start-agent/react.ts @@ -16,8 +16,8 @@ export type QuickStartAgentState = | { status: 'error'; message: string } export interface UseQuickStartAgentOptions extends CreateQuickStartAgentOptions { - /** 测试可替换;生产至少等待一帧,保证最终 Prompt 先于付费写操作可见。 */ - waitForPresentation?: () => Promise + /** 测试可替换;页面可在展示提案后返回用户编辑过的最终 Prompt。 */ + waitForPresentation?: (plan: CharacterGenerationPlan) => Promise } async function waitForBrowserPresentation(): Promise { @@ -28,7 +28,15 @@ async function waitForBrowserPresentation(): Promise { } function errorMessage(cause: unknown): string { - return cause instanceof Error && cause.message ? cause.message : 'Agent 暂时不可用,请稍后重试' + if (!(cause instanceof Error) || !cause.message) return 'Agent 暂时不可用,请稍后重试' + if ( + /Tool|Planner|start_character_generation|optimizedPrompt|optimizationSummary|生成授权/u.test( + cause.message, + ) + ) { + return '提示词优化没有完成,请重新发送' + } + return cause.message } export function useQuickStartAgent({ @@ -84,7 +92,7 @@ export function useQuickStartAgent({ signal: controller.signal, async onBeforeDispatch(plan) { if (mounted.current) setState({ status: 'dispatching', ...plan }) - await waitForPresentation() + return await waitForPresentation(plan) }, }) if (result.kind === 'message' && mounted.current) { diff --git a/frontend/src/features/quick-start-agent/runtime.ts b/frontend/src/features/quick-start-agent/runtime.ts index 6b133ef0..74a6e92f 100644 --- a/frontend/src/features/quick-start-agent/runtime.ts +++ b/frontend/src/features/quick-start-agent/runtime.ts @@ -1,8 +1,7 @@ export const START_CHARACTER_GENERATION_TOOL = 'start_character_generation' as const const MAX_PROMPT_LENGTH = 4_000 -const MAX_ASSUMPTIONS = 6 -const MAX_ASSUMPTION_LENGTH = 200 +const MAX_OPTIMIZATION_SUMMARY_LENGTH = 600 export interface PlannerMessage { role: 'user' | 'assistant' @@ -30,7 +29,7 @@ export type QuickStartPlanner = (input: PlannerInput) => Promise export interface CharacterGenerationPlan { optimizedPrompt: string - assumptions: readonly string[] + optimizationSummary: string } export type ValidatedPlannerTerminal = @@ -43,8 +42,8 @@ export type QuickStartAgentResult = export interface QuickStartAgentTurnOptions { signal?: AbortSignal - /** 页面在此处先渲染实际 Prompt 与默认假设;回调完成前不得发起付费写操作。 */ - onBeforeDispatch?: (plan: CharacterGenerationPlan) => void | Promise + /** 页面先展示提案并等待用户确认;返回值可覆盖最终提交的 Prompt。 */ + onBeforeDispatch?: (plan: CharacterGenerationPlan) => string | void | Promise } export interface QuickStartAgent { @@ -71,9 +70,9 @@ export function parseCharacterGenerationPlan(value: unknown): CharacterGeneratio if (!isRecord(value)) throw new Error('生成 Tool 参数必须是对象') const keys = Object.keys(value) if ( - keys.some((key) => key !== 'optimizedPrompt' && key !== 'assumptions') || + keys.some((key) => key !== 'optimizedPrompt' && key !== 'optimizationSummary') || !keys.includes('optimizedPrompt') || - !keys.includes('assumptions') + !keys.includes('optimizationSummary') ) { throw new Error('生成 Tool 参数字段无效') } @@ -83,17 +82,12 @@ export function parseCharacterGenerationPlan(value: unknown): CharacterGeneratio if (!optimizedPrompt || optimizedPrompt.length > MAX_PROMPT_LENGTH) { throw new Error('生成 Tool 的 optimizedPrompt 无效') } - if (!Array.isArray(value.assumptions) || value.assumptions.length > MAX_ASSUMPTIONS) { - throw new Error('生成 Tool 的 assumptions 无效') + const optimizationSummary = + typeof value.optimizationSummary === 'string' ? value.optimizationSummary.trim() : '' + if (!optimizationSummary || optimizationSummary.length > MAX_OPTIMIZATION_SUMMARY_LENGTH) { + throw new Error('生成 Tool 的 optimizationSummary 无效') } - const assumptions = value.assumptions.map((assumption) => { - const normalized = typeof assumption === 'string' ? assumption.trim() : '' - if (!normalized || normalized.length > MAX_ASSUMPTION_LENGTH) { - throw new Error('生成 Tool 的 assumptions 无效') - } - return normalized - }) - return { optimizedPrompt, assumptions } + return { optimizedPrompt, optimizationSummary } } /** SDK 完整返回后再做一次 fail-closed 终态校验,校验通过前不触发业务 action。 */ @@ -175,21 +169,27 @@ export function createQuickStartAgent({ const plan: CharacterGenerationPlan = { optimizedPrompt: terminal.optimizedPrompt, - assumptions: terminal.assumptions, + optimizationSummary: terminal.optimizationSummary, } - await onBeforeDispatch?.(plan) + const promptOverride = await onBeforeDispatch?.(plan) if (signal?.aborted) { revoked = true throw abortError() } assertAuthorized() + const effectivePrompt = + promptOverride === undefined ? plan.optimizedPrompt : promptOverride.trim() + if (!effectivePrompt || effectivePrompt.length > MAX_PROMPT_LENGTH) { + throw new Error('确认后的角色提示词无效') + } + // 写权限在调用前消费。即使响应丢失,也不得自动重放可能已经计费的 action。 consumed = true const { runId } = await startCharacterGeneration({ - prompt: plan.optimizedPrompt, + prompt: effectivePrompt, }) - return { kind: 'generated', runId, ...plan } + return { kind: 'generated', runId, ...plan, optimizedPrompt: effectivePrompt } } finally { running = false } diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 1b4b561b..1b783fae 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -9,7 +9,7 @@ import { type FormEvent, type ReactNode, } from 'react' -import { ArrowUp, ImageSquare, X } from '@phosphor-icons/react' +import { ArrowBendDownLeft, ArrowUp, ImageSquare, X } from '@phosphor-icons/react' import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router' import { @@ -265,12 +265,6 @@ function readAgentRunConversation( } } -function planConfirmationCopy(assumptions: readonly string[]): string { - return assumptions.length > 0 - ? `提示词优化已完成。\n默认处理:${assumptions.join('、')}。确认后点击发送。` - : '提示词优化已完成。请检查输入框,确认后点击发送。' -} - function playtestPath(characterId: string, outfitId: string, actionId?: string): string { const path = `/playtest/${encodeURIComponent(characterId)}/${encodeURIComponent(outfitId)}` return actionId ? `${path}?${new URLSearchParams({ actionId })}` : path @@ -452,15 +446,16 @@ function QuickStartInput({ }, ) const fileInput = useRef(null) + const promptInput = useRef(null) const submitAbortController = useRef(null) const handoffTimer = useRef | null>(null) const rewriteTimer = useRef | null>(null) - const generationConfirmation = useRef<(() => void) | null>(null) + const generationConfirmation = useRef<((prompt: string) => void) | null>(null) const conversationTurnsRef = useRef(conversationTurns) const initialConversationLength = useRef(conversationTurns.length) const waitForGenerationConfirmation = useCallback( () => - new Promise((resolve) => { + new Promise((resolve) => { generationConfirmation.current = resolve }), [], @@ -541,32 +536,25 @@ function QuickStartInput({ submitAbortController.current?.abort() if (handoffTimer.current) clearTimeout(handoffTimer.current) if (rewriteTimer.current) clearTimeout(rewriteTimer.current) - generationConfirmation.current?.() + generationConfirmation.current?.('') generationConfirmation.current = null }, [], ) - useEffect(() => { + function fillOptimizedPrompt() { const state = agentSession.state - if (state.status !== 'dispatching') return + if (state.status !== 'dispatching' || promptState === 'rewriting' || generationStarting) return setPrompt(state.optimizedPrompt) setPromptState('rewriting') + if (rewriteTimer.current) clearTimeout(rewriteTimer.current) rewriteTimer.current = setTimeout(() => { rewriteTimer.current = null setPromptState('ready') - appendConversationTurn({ - role: 'assistant', - content: planConfirmationCopy(state.assumptions), - }) + promptInput.current?.focus() }, PROMPT_REWRITE_MS) - - return () => { - if (rewriteTimer.current) clearTimeout(rewriteTimer.current) - rewriteTimer.current = null - } - }, [agentSession.state, appendConversationTurn]) + } function selectTemplateFile(event: ChangeEvent) { if (entryBusy) return @@ -585,11 +573,12 @@ function QuickStartInput({ const normalizedPrompt = prompt.trim() if (agentSession.state.status === 'dispatching' && promptState === 'ready') { + if (!normalizedPrompt) return const confirm = generationConfirmation.current if (!confirm) return generationConfirmation.current = null setPromptState('confirmed') - confirm() + confirm(normalizedPrompt) return } @@ -659,7 +648,11 @@ function QuickStartInput({ } const inputLocked = - submitting || agentPlanning || promptState === 'rewriting' || generationStarting + submitting || + agentPlanning || + promptState === 'rewriting' || + generationStarting || + (agentSession.state.status === 'dispatching' && promptState === 'collecting') const awaitingGenerationConfirmation = agentSession.state.status === 'dispatching' && promptState === 'ready' const buttonLabel = submitting @@ -668,17 +661,20 @@ function QuickStartInput({ ? '正在判断…' : promptState === 'rewriting' ? '优化中' - : awaitingGenerationConfirmation - ? '发送生成' - : generationStarting - ? '正在开始生成…' - : agentSession.state.status === 'restart-required' - ? '重新开始' - : agentSession.state.status === 'awaiting-input' - ? '继续' - : '生成角色' - const canSubmit = - awaitingGenerationConfirmation || Boolean(prompt.trim()) || Boolean(templateFile) + : agentSession.state.status === 'dispatching' && promptState === 'collecting' + ? '先填入提示词' + : awaitingGenerationConfirmation + ? '发送生成' + : generationStarting + ? '正在开始生成…' + : agentSession.state.status === 'restart-required' + ? '重新开始' + : agentSession.state.status === 'awaiting-input' + ? '继续' + : '生成角色' + const canSubmit = awaitingGenerationConfirmation + ? Boolean(prompt.trim()) + : Boolean(prompt.trim()) || Boolean(templateFile) return (
) : null} + {agentSession.state.status === 'dispatching' ? ( + + ) : null} {agentSession.state.status === 'error' ? (
@@ -809,16 +813,19 @@ function QuickStartInput({ hasConversation ? 'sm:grid-cols-[1fr_auto]' : 'sm:grid-cols-[1fr_auto_auto]' }`} > -
+ ) +} + function AgentCopy({ lines, tone = 'default', From 0be6264bdfb92ed0f8feea18d9e78be2dd93cb81 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 17:58:06 +0800 Subject: [PATCH 2/3] test(quick-start-agent): cover editable prompt proposals Prompt proposal changes span the planner protocol, runtime handoff, React state, and page interaction. Update focused fixtures and regressions for user-facing summaries, sanitized errors, editable confirmation, and adaptive layout. Protect the explicit fill-before-generate boundary from future regressions. --- .../planner.protocol.test.ts | 9 +- .../quick-start-agent/planner.test.ts | 15 +++- .../features/quick-start-agent/react.test.tsx | 38 +++++++- .../quick-start-agent/runtime.test.ts | 53 +++++++---- frontend/src/pages/quick-start/index.test.tsx | 89 +++++++++++++++---- 5 files changed, 158 insertions(+), 46 deletions(-) diff --git a/frontend/src/features/quick-start-agent/planner.protocol.test.ts b/frontend/src/features/quick-start-agent/planner.protocol.test.ts index 0b3e6c21..6936bc96 100644 --- a/frontend/src/features/quick-start-agent/planner.protocol.test.ts +++ b/frontend/src/features/quick-start-agent/planner.protocol.test.ts @@ -62,7 +62,7 @@ describe('AI SDK OpenAI-compatible protocol fixture', () => { function: { name: 'start_character_generation', arguments: - '{"optimizedPrompt":"银发像素骑士全身像","assumptions":["默认单角色"]}', + '{"optimizedPrompt":"银发像素骑士全身像","optimizationSummary":"我会保留银发骑士特征,并整理为完整的全身母版描述。"}', }, }, ], @@ -83,7 +83,10 @@ describe('AI SDK OpenAI-compatible protocol fixture', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '银发像素骑士全身像', assumptions: ['默认单角色'] }, + input: { + optimizedPrompt: '银发像素骑士全身像', + optimizationSummary: '我会保留银发骑士特征,并整理为完整的全身母版描述。', + }, }, ], }) @@ -103,7 +106,7 @@ describe('AI SDK OpenAI-compatible protocol fixture', () => { type: 'function', function: { name: 'start_character_generation', - arguments: '{"optimizedPrompt":"","assumptions":[]}', + arguments: '{"optimizedPrompt":"","optimizationSummary":"我会整理角色描述。"}', }, }, ], diff --git a/frontend/src/features/quick-start-agent/planner.test.ts b/frontend/src/features/quick-start-agent/planner.test.ts index fc2d47bb..aaa900bd 100644 --- a/frontend/src/features/quick-start-agent/planner.test.ts +++ b/frontend/src/features/quick-start-agent/planner.test.ts @@ -15,9 +15,10 @@ describe('quickStartPlannerInstructions', () => { expect(firstTurn).toContain('直接生成') expect(firstTurn).toContain('不要生成') expect(firstTurn).toContain('不得依赖关键词匹配') - expect(firstTurn).toContain('动作将在角色母版确认后处理') + expect(firstTurn).toContain('交给用户检查和编辑') + expect(firstTurn).toContain('不得输出思维过程') expect(laterTurn).toContain('追问额度已经用完') - expect(laterTurn).toContain('默认假设') + expect(laterTurn).toContain('必要补全直接写入 optimizedPrompt') }) }) @@ -30,7 +31,10 @@ describe('createAiSdkQuickStartPlanner', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '银发像素骑士全身像', assumptions: ['默认单角色'] }, + input: { + optimizedPrompt: '银发像素骑士全身像', + optimizationSummary: '我会保留银发骑士特征,并整理为完整的全身母版描述。', + }, }, ], })) @@ -53,7 +57,10 @@ describe('createAiSdkQuickStartPlanner', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '银发像素骑士全身像', assumptions: ['默认单角色'] }, + input: { + optimizedPrompt: '银发像素骑士全身像', + optimizationSummary: '我会保留银发骑士特征,并整理为完整的全身母版描述。', + }, }, ], }) diff --git a/frontend/src/features/quick-start-agent/react.test.tsx b/frontend/src/features/quick-start-agent/react.test.tsx index b2d9dd70..45398b0e 100644 --- a/frontend/src/features/quick-start-agent/react.test.tsx +++ b/frontend/src/features/quick-start-agent/react.test.tsx @@ -32,7 +32,7 @@ describe('useQuickStartAgent', () => { toolName: 'start_character_generation', input: { optimizedPrompt: '银发像素骑士,全身像', - assumptions: ['默认单角色', '默认横版视角'], + optimizationSummary: '我会保留银发骑士特征,并补全适合母版生成的全身描述。', }, }, ], @@ -64,7 +64,7 @@ describe('useQuickStartAgent', () => { expect(result.current.state).toEqual({ status: 'dispatching', optimizedPrompt: '银发像素骑士,全身像', - assumptions: ['默认单角色', '默认横版视角'], + optimizationSummary: '我会保留银发骑士特征,并补全适合母版生成的全身描述。', }), ) expect(startCharacterGeneration).not.toHaveBeenCalled() @@ -150,6 +150,30 @@ describe('useQuickStartAgent', () => { }) }) + it('does not expose Agent protocol details in the user-facing error state', async () => { + const planner = vi.fn(async () => ({ + text: '', + finishReason: 'tool-calls', + toolCalls: [ + { + toolName: 'start_character_generation', + input: { optimizedPrompt: '像素骑士' }, + }, + ], + })) + const startCharacterGeneration = vi.fn(async () => ({ runId: 'run-agent' })) + const { result } = renderHook(() => useQuickStartAgent({ planner, startCharacterGeneration })) + + await act(async () => { + await expect(result.current.submit('像素骑士')).rejects.toThrow('生成 Tool 参数字段无效') + }) + + expect(result.current.state).toEqual({ + status: 'error', + message: '提示词优化没有完成,请重新发送', + }) + }) + it('keeps the first successful clarification available after an initial Planner failure', async () => { const planner = vi .fn<(input: PlannerInput) => Promise>() @@ -184,7 +208,10 @@ describe('useQuickStartAgent', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '银发像素骑士,全身像', assumptions: [] }, + input: { + optimizedPrompt: '银发像素骑士,全身像', + optimizationSummary: '我会保留银发骑士特征,并整理为完整的全身母版描述。', + }, }, ], }), @@ -211,7 +238,10 @@ describe('useQuickStartAgent', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '银发像素骑士,全身像', assumptions: [] }, + input: { + optimizedPrompt: '银发像素骑士,全身像', + optimizationSummary: '我会保留银发骑士特征,并整理为完整的全身母版描述。', + }, }, ], }, diff --git a/frontend/src/features/quick-start-agent/runtime.test.ts b/frontend/src/features/quick-start-agent/runtime.test.ts index f8fa9ed1..075b822e 100644 --- a/frontend/src/features/quick-start-agent/runtime.test.ts +++ b/frontend/src/features/quick-start-agent/runtime.test.ts @@ -16,7 +16,10 @@ function plannerResult(overrides: Partial = {}): PlannerResult { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '完整身体的银发像素骑士', assumptions: ['默认单角色'] }, + input: { + optimizedPrompt: '完整身体的银发像素骑士', + optimizationSummary: '我会保留银发骑士的身份特征,并补全适合角色母版的全身描述。', + }, }, ], ...overrides, @@ -37,7 +40,7 @@ describe('validatePlannerTerminal', () => { expect(validatePlannerTerminal(plannerResult())).toEqual({ kind: 'tool', optimizedPrompt: '完整身体的银发像素骑士', - assumptions: ['默认单角色'], + optimizationSummary: '我会保留银发骑士的身份特征,并补全适合角色母版的全身描述。', }) }) @@ -67,7 +70,10 @@ describe('validatePlannerTerminal', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: ' ', assumptions: ['默认单角色'] }, + input: { + optimizedPrompt: ' ', + optimizationSummary: '我会整理角色描述。', + }, }, ], }), @@ -88,18 +94,18 @@ describe('parseCharacterGenerationPlan', () => { it.each([ [ 'unknown fields', - { optimizedPrompt: '像素骑士', assumptions: [], unexpected: true }, + { + optimizedPrompt: '像素骑士', + optimizationSummary: '我会整理角色描述。', + unexpected: true, + }, '生成 Tool 参数字段无效', ], + ['missing optimization summary', { optimizedPrompt: '像素骑士' }, '生成 Tool 参数字段无效'], [ - 'non-array assumptions', - { optimizedPrompt: '像素骑士', assumptions: '默认单角色' }, - '生成 Tool 的 assumptions 无效', - ], - [ - 'empty assumption', - { optimizedPrompt: '像素骑士', assumptions: [' '] }, - '生成 Tool 的 assumptions 无效', + 'empty optimization summary', + { optimizedPrompt: '像素骑士', optimizationSummary: ' ' }, + '生成 Tool 的 optimizationSummary 无效', ], ])('rejects %s', (_label, input, message) => { expect(() => parseCharacterGenerationPlan(input)).toThrow(message) @@ -144,7 +150,7 @@ describe('createQuickStartAgent', () => { expect(startCharacterGeneration).not.toHaveBeenCalled() }) - it('dispatches the injected write action once after exposing the final prompt', async () => { + it('dispatches the user-edited prompt once after presenting the proposal', async () => { const { agent, startCharacterGeneration } = fixture() const events: string[] = [] startCharacterGeneration.mockImplementation(async () => { @@ -156,22 +162,35 @@ describe('createQuickStartAgent', () => { agent.start('银发骑士', { onBeforeDispatch: async (plan) => { events.push(`visible:${plan.optimizedPrompt}`) + return '完整身体的银发像素骑士,深蓝斗篷' }, }), ).resolves.toEqual({ kind: 'generated', runId: 'run-1', - optimizedPrompt: '完整身体的银发像素骑士', - assumptions: ['默认单角色'], + optimizedPrompt: '完整身体的银发像素骑士,深蓝斗篷', + optimizationSummary: '我会保留银发骑士的身份特征,并补全适合角色母版的全身描述。', }) expect(events).toEqual(['visible:完整身体的银发像素骑士', 'action']) expect(startCharacterGeneration).toHaveBeenCalledTimes(1) expect(startCharacterGeneration).toHaveBeenCalledWith({ - prompt: '完整身体的银发像素骑士', + prompt: '完整身体的银发像素骑士,深蓝斗篷', }) }) + it('rejects an explicitly cleared prompt without dispatching the write action', async () => { + const { agent, startCharacterGeneration } = fixture() + + await expect( + agent.start('银发骑士', { + onBeforeDispatch: async () => ' ', + }), + ).rejects.toThrow('确认后的角色提示词无效') + + expect(startCharacterGeneration).not.toHaveBeenCalled() + }) + it('keeps a text-only response side-effect free and spends at most one clarification', async () => { const { agent, planner, startCharacterGeneration } = fixture( plannerResult({ text: '最希望保留什么外观特征?', finishReason: 'stop', toolCalls: [] }), @@ -220,7 +239,7 @@ describe('createQuickStartAgent', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '', assumptions: [] }, + input: { optimizedPrompt: '', optimizationSummary: '我会整理角色描述。' }, }, ], }), diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index ab996ec0..3d7eef2b 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -174,7 +174,10 @@ function agentFor( toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: messages.at(-1)?.content ?? '', assumptions: [] }, + input: { + optimizedPrompt: messages.at(-1)?.content ?? '', + optimizationSummary: '我会保留角色的核心特征,并整理成适合母版生成的完整描述。', + }, }, ], })), @@ -227,6 +230,8 @@ function renderInBrowserHistory( } async function confirmAgentGeneration() { + const fill = await screen.findByRole('button', { name: '填入输入框' }) + fireEvent.click(fill) const send = await screen.findByRole('button', { name: '发送生成' }) fireEvent.click(send) await act(async () => undefined) @@ -487,12 +492,20 @@ describe('QuickStartPage', () => { ) }) - it('keeps the entry composer on a single line', () => { + it('keeps the entry composer compact before expanding with content', () => { renderAt('/quick-start', serviceFor(null)) const composer = screen.getByRole('textbox', { name: '创作指令' }) - - expect(composer.tagName).toBe('INPUT') - expect(composer.className).toContain('h-10') + const editingSurface = composer.closest('label') + + expect(composer.tagName).toBe('TEXTAREA') + expect((composer as HTMLTextAreaElement).rows).toBe(1) + expect(composer.className).toContain('min-h-10') + expect(composer.className).toContain('[field-sizing:content]') + expect(composer.className).toContain('px-4') + expect(editingSurface?.className).toContain('ml-2') + expect(editingSurface?.className).toContain('rounded-lg') + expect(editingSurface?.className).not.toContain('bg-app-surface') + expect(screen.getByRole('button', { name: '生成角色' }).className).toContain('self-end') expect(composer.closest('form')?.className).toContain( 'focus-within:shadow-[var(--shadow-app-composer-focus)]', ) @@ -703,7 +716,7 @@ describe('QuickStartPage', () => { expect(window.localStorage.getItem(legacyKey)).toBeNull() }) - it('rewrites the real optimized prompt in the composer and waits for explicit generation send', async () => { + it('keeps the proposal in chat until the user fills, edits, and sends it', async () => { vi.useFakeTimers() const service = serviceFor(null) const startCharacterGeneration = vi.fn(() => new Promise<{ runId: string }>(() => undefined)) @@ -717,7 +730,30 @@ describe('QuickStartPage', () => { await act(async () => undefined) const composer = screen.getByTestId('quick-start-composer') - const input = screen.getByRole('textbox', { name: '创作指令' }) as HTMLInputElement + const input = screen.getByRole('textbox', { name: '创作指令' }) as HTMLTextAreaElement + expect(input.tagName).toBe('TEXTAREA') + expect(input.rows).toBe(1) + expect(input.className).toContain('[field-sizing:content]') + expect(input.value).toBe('') + expect( + screen.getByText('我会保留角色的核心特征,并整理成适合母版生成的完整描述。'), + ).toBeTruthy() + expect(screen.getAllByText('云端工坊的银发机械师')).toHaveLength(2) + expect(screen.queryByText(/默认处理|Tool|确认后点击发送/u)).toBeNull() + expect(startCharacterGeneration).not.toHaveBeenCalled() + + const proposal = screen + .getAllByText('云端工坊的银发机械师') + .find((element) => element.tagName === 'BLOCKQUOTE') + ?.closest('[data-prompt-proposal]') + const optimizedCopy = proposal?.querySelector('blockquote') + expect(optimizedCopy?.className).not.toContain('border-l') + expect(optimizedCopy?.className).not.toContain('pl-4') + const fill = screen.getByRole('button', { name: '填入输入框' }) + expect(fill.className).not.toContain('border') + expect(fill.textContent).toContain('填入输入框后,还可以继续修改') + fireEvent.click(fill) + expect(composer.dataset.promptState).toBe('rewriting') const promptRewrite = composer.querySelector('[data-prompt-rewrite]') expect(promptRewrite?.querySelector('[data-copy-motion-mode="characters"]')).toBeTruthy() @@ -727,12 +763,19 @@ describe('QuickStartPage', () => { await act(async () => vi.advanceTimersByTimeAsync(760)) expect(composer.dataset.promptState).toBe('ready') expect(input.value).toBe('云端工坊的银发机械师') + expect(input.hasAttribute('readonly')).toBe(false) + fireEvent.change(input, { target: { value: ' ' } }) + expect(screen.getByRole('button', { name: '发送生成' }).hasAttribute('disabled')).toBe(true) + expect(startCharacterGeneration).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: '云端工坊的银发机械师,佩戴黄铜护目镜' } }) expect(screen.getByRole('button', { name: '发送生成' })).toBeTruthy() expect(startCharacterGeneration).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: '发送生成' })) await act(async () => undefined) - expect(startCharacterGeneration).toHaveBeenCalledTimes(1) + expect(startCharacterGeneration).toHaveBeenCalledWith({ + prompt: '云端工坊的银发机械师,佩戴黄铜护目镜', + }) }) it('keeps one persistent Agent shell with a floating composer outside the scrolling transcript', async () => { @@ -982,10 +1025,12 @@ describe('QuickStartPage', () => { }) fireEvent.click(screen.getByRole('button', { name: '生成角色' })) await act(async () => undefined) - expect(screen.getByText('提着风灯的森林守夜人')).toBeTruthy() - expect(screen.getByTestId('quick-start-composer').dataset.promptState).toBe('rewriting') + expect(screen.getAllByText('提着风灯的森林守夜人')).toHaveLength(2) + expect(screen.getByTestId('quick-start-composer').dataset.promptState).toBe('collecting') expect(screen.queryByTestId('quick-start-run')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '填入输入框' })) + expect(screen.getByTestId('quick-start-composer').dataset.promptState).toBe('rewriting') await act(async () => vi.advanceTimersByTimeAsync(760)) expect(screen.getByRole('button', { name: '发送生成' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '发送生成' })) @@ -1125,7 +1170,7 @@ describe('QuickStartPage', () => { expect(screen.queryByRole('button', { name: /暗黑哥特像素/u })).toBeNull() }) - it('asks once, then shows the optimized description and assumptions before the Controller action', async () => { + it('asks once, then presents a user-facing proposal without internal assumptions', async () => { const action = deferred<{ runId: string }>() const plannerResults: PlannerResult[] = [ { text: '请补充角色的美术风格。', finishReason: 'stop', toolCalls: [] }, @@ -1137,7 +1182,8 @@ describe('QuickStartPage', () => { toolName: 'start_character_generation', input: { optimizedPrompt: '银发骑士,16-bit 像素风,全身像', - assumptions: ['默认单角色', '动作稍后处理'], + optimizationSummary: + '我会保留银发骑士和 16-bit 风格,并整理为轮廓清楚的全身母版描述。', }, }, ], @@ -1156,11 +1202,15 @@ describe('QuickStartPage', () => { target: { value: '16-bit 像素风,请直接生成' }, }) fireEvent.click(screen.getByRole('button', { name: '继续' })) - const optimizedPrompt = (await screen.findByRole('textbox', { + const composerInput = (await screen.findByRole('textbox', { name: '创作指令', })) as HTMLInputElement - await waitFor(() => expect(optimizedPrompt.value).toBe('银发骑士,16-bit 像素风,全身像')) - expect(await screen.findByText(/默认处理:默认单角色、动作稍后处理/u)).toBeTruthy() + expect(composerInput.value).toBe('') + expect( + await screen.findByText('我会保留银发骑士和 16-bit 风格,并整理为轮廓清楚的全身母版描述。'), + ).toBeTruthy() + expect(screen.getByText('银发骑士,16-bit 像素风,全身像')).toBeTruthy() + expect(screen.queryByText(/默认处理|动作稍后处理|确认后点击发送/u)).toBeNull() expect(startCharacterGeneration).not.toHaveBeenCalled() await confirmAgentGeneration() @@ -1179,7 +1229,10 @@ describe('QuickStartPage', () => { toolCalls: [ { toolName: 'start_character_generation', - input: { optimizedPrompt: '银发像素骑士,全身像', assumptions: [] }, + input: { + optimizedPrompt: '银发像素骑士,全身像', + optimizationSummary: '我会保留银发骑士特征,并整理为完整的全身母版描述。', + }, }, ], }, @@ -1318,14 +1371,14 @@ describe('QuickStartPage', () => { expect((await screen.findByRole('alert')).textContent).toContain('服务繁忙') }) - it('keeps the uploaded template controls in the single-line composer', () => { + it('keeps the uploaded template controls in the adaptive composer', () => { renderAt('/quick-start', serviceFor(null)) const file = new File(['pixels'], 'hero.png', { type: 'image/png' }) fireEvent.change(screen.getByLabelText('上传角色母版'), { target: { files: [file] } }) const composer = screen.getByLabelText('创作指令').closest('form') - expect(screen.getByLabelText('创作指令').tagName).toBe('INPUT') + expect(screen.getByLabelText('创作指令').tagName).toBe('TEXTAREA') expect(composer?.textContent).toContain('hero.png') expect(screen.getByRole('button', { name: '移除图片' }).closest('form')).toBe(composer) expect(composer?.querySelector('[data-layout="quick-start-attachment-row"]')).toBeNull() From f5eb1fcc3b0f82fe45814f4dac91635b3a61f12e Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 18:29:54 +0800 Subject: [PATCH 3/3] test(quick-start): follow prompt confirmation in draft migration Latest main added a draft migration regression that still followed the retired automatic prompt insertion. Fill the Agent proposal before advancing the rewrite timer and sending generation. Keep the run-sidecar migration assertions aligned with the user-confirmed flow. --- frontend/src/pages/quick-start/index.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 3d7eef2b..602f82b3 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -659,6 +659,7 @@ describe('QuickStartPage', () => { window.sessionStorage.getItem(`windup.quick-start.agent-chat.v2:draft:7:${draftId}`), ).toContain('提着风灯的森林守夜人') + fireEvent.click(screen.getByRole('button', { name: '填入输入框' })) await act(async () => vi.advanceTimersByTimeAsync(760)) fireEvent.click(screen.getByRole('button', { name: '发送生成' })) await act(async () => undefined)