diff --git a/web/src/constants/requirementPhasePrompts.test.ts b/web/src/constants/requirementPhasePrompts.test.ts index 0ba23ee..c970950 100644 --- a/web/src/constants/requirementPhasePrompts.test.ts +++ b/web/src/constants/requirementPhasePrompts.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest' import type { Artifact } from '@/api/projects' import { buildRequirementSystemPrompt, + INTERACTIVE_QUESTION_PROMPT, PHASE_ROLE_PROMPTS, PREV_ARTIFACT_PHASE, } from './requirementPhasePrompts' @@ -26,6 +27,7 @@ describe('buildRequirementSystemPrompt', () => { it('planning:角色模板 + 需求信息,无上一阶段产物', () => { const out = buildRequirementSystemPrompt('planning', req) expect(out).toContain(PHASE_ROLE_PROMPTS.planning) + expect(out).toContain(INTERACTIVE_QUESTION_PROMPT) expect(out).toContain('需求 #7:导出报表') expect(out).toContain('支持导出 Excel') expect(out).not.toContain('上一阶段') diff --git a/web/src/constants/requirementPhasePrompts.ts b/web/src/constants/requirementPhasePrompts.ts index b9dde62..9f7d6ee 100644 --- a/web/src/constants/requirementPhasePrompts.ts +++ b/web/src/constants/requirementPhasePrompts.ts @@ -14,6 +14,33 @@ export const PHASE_ROLE_PROMPTS: Record = { 评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`, } +export const INTERACTIVE_QUESTION_PROMPT = [ + '## 交互式澄清问题协议', + '当信息不足且用户直接输入大段文字的成本较高时,优先提出一个结构化澄清问题,方便用户点选。', + '每次最多输出 1 个结构化问题;问题必须有 2-5 个选项;只有选项允许多选时 mode 才使用 multiple。', + '结构化问题必须放在 acw-question 代码块中,代码块内只能是合法 JSON,不能输出 HTML。', + '字段格式如下:', + '```acw-question', + '{', + ' "version": 1,', + ' "id": "stable_question_id",', + ' "title": "需要用户确认的问题",', + ' "description": "选择这个问题的原因,可省略",', + ' "mode": "single",', + ' "options": [', + ' {', + ' "id": "stable_option_id",', + ' "label": "给用户看的短选项",', + ' "summary": "一句话解释,可省略",', + ' "details": "更完整的影响说明,可省略"', + ' }', + ' ],', + ' "allowCustom": true', + '}', + '```', + '用户回答结构化问题后,继续基于回答推进本阶段讨论或产物输出。', +].join('\n') + // 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。 export const PREV_ARTIFACT_PHASE: Record = { planning: null, @@ -28,7 +55,7 @@ export function buildRequirementSystemPrompt( requirement: Pick, prevArtifact?: Artifact | null, ): string { - const parts: string[] = [PHASE_ROLE_PROMPTS[phase]] + const parts: string[] = [PHASE_ROLE_PROMPTS[phase], INTERACTIVE_QUESTION_PROMPT] parts.push(`## 当前需求\n需求 #${requirement.number}:${requirement.title}`) if (requirement.description) { parts.push(`### 需求描述\n${requirement.description}`) diff --git a/web/src/utils/interactiveMessage.test.ts b/web/src/utils/interactiveMessage.test.ts new file mode 100644 index 0000000..303a61b --- /dev/null +++ b/web/src/utils/interactiveMessage.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { formatInteractiveQuestionAnswer, parseInteractiveMessage } from './interactiveMessage' + +const questionBlock = `\`\`\`acw-question +{ + "version": 1, + "id": "target_user", + "title": "这个需求主要面向谁?", + "description": "选择后我会补全场景。", + "mode": "single", + "options": [ + { + "id": "admin", + "label": "后台管理员", + "summary": "负责配置和审核", + "details": "更关注权限、批量操作和审计。" + }, + { + "id": "user", + "label": "普通用户" + } + ], + "allowCustom": true +} +\`\`\`` + +describe('interactiveMessage', () => { + it('解析 acw-question 代码块并保留普通 Markdown', () => { + const parsed = parseInteractiveMessage(`# 前置说明\n\n${questionBlock}`) + + expect(parsed.markdown).toBe('# 前置说明') + expect(parsed.question?.id).toBe('target_user') + expect(parsed.question?.options[0]).toMatchObject({ + id: 'admin', + label: '后台管理员', + summary: '负责配置和审核', + }) + }) + + it('非法 JSON 时回退为普通 Markdown', () => { + const content = '```acw-question\n{ broken }\n```' + const parsed = parseInteractiveMessage(content) + + expect(parsed.question).toBeNull() + expect(parsed.markdown).toBe(content) + }) + + it('选项数量不合法时回退为普通 Markdown', () => { + const content = `\`\`\`acw-question +{"version":1,"id":"q","title":"Q","mode":"single","options":[{"id":"a","label":"A"}]} +\`\`\`` + const parsed = parseInteractiveMessage(content) + + expect(parsed.question).toBeNull() + expect(parsed.markdown).toBe(content) + }) + + it('格式化用户选择为稳定文本', () => { + const parsed = parseInteractiveMessage(questionBlock) + expect(parsed.question).not.toBeNull() + + const text = formatInteractiveQuestionAnswer({ + question: parsed.question!, + selectedOptionIds: ['admin'], + customText: '还需要导出日志', + }) + + expect(text).toContain('question_id: target_user') + expect(text).toContain('- admin: 后台管理员 - 负责配置和审核') + expect(text).toContain('还需要导出日志') + }) +}) diff --git a/web/src/utils/interactiveMessage.ts b/web/src/utils/interactiveMessage.ts new file mode 100644 index 0000000..9051d37 --- /dev/null +++ b/web/src/utils/interactiveMessage.ts @@ -0,0 +1,161 @@ +const QUESTION_FENCE_RE = /(^|\n)```acw-question[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\n|$)/gi + +export type InteractiveQuestionMode = 'single' | 'multiple' + +export interface InteractiveQuestionOption { + id: string + label: string + summary?: string + details?: string +} + +export interface InteractiveQuestion { + version: 1 + id: string + title: string + description?: string + mode: InteractiveQuestionMode + options: InteractiveQuestionOption[] + allowCustom: boolean +} + +export interface ParsedInteractiveMessage { + markdown: string + question: InteractiveQuestion | null +} + +export interface InteractiveQuestionAnswer { + question: InteractiveQuestion + selectedOptionIds: string[] + customText: string +} + +interface FenceMatch { + raw: string + json: string +} + +export function parseInteractiveMessage(content: string): ParsedInteractiveMessage { + const matches = findQuestionFences(content) + for (const match of matches) { + const question = parseQuestionJSON(match.json) + if (!question) continue + return { + markdown: content.replace(match.raw, '').trim(), + question, + } + } + return { markdown: content, question: null } +} + +export function formatInteractiveQuestionAnswer(answer: InteractiveQuestionAnswer): string { + const selected = answer.selectedOptionIds + .map((id) => answer.question.options.find((option) => option.id === id)) + .filter((option): option is InteractiveQuestionOption => option != null) + + const selectedLines = + selected.length > 0 + ? selected.map((option) => { + const summary = option.summary ? ` - ${option.summary}` : '' + return `- ${option.id}: ${option.label}${summary}` + }) + : ['- none'] + + const customText = answer.customText.trim() || '无' + return [ + '我选择了结构化问题的答案。', + '', + `question_id: ${answer.question.id}`, + `question: ${answer.question.title}`, + 'selected_options:', + ...selectedLines, + '', + '补充说明:', + customText, + ].join('\n') +} + +function findQuestionFences(content: string): FenceMatch[] { + const matches: FenceMatch[] = [] + QUESTION_FENCE_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = QUESTION_FENCE_RE.exec(content)) != null) { + matches.push({ raw: match[0], json: match[2] }) + } + return matches +} + +function parseQuestionJSON(json: string): InteractiveQuestion | null { + let value: unknown + try { + value = JSON.parse(json) + } catch { + return null + } + return normalizeQuestion(value) +} + +function normalizeQuestion(value: unknown): InteractiveQuestion | null { + const record = asRecord(value) + if (!record) return null + if (record.version !== 1) return null + + const id = requiredString(record.id) + const title = requiredString(record.title) + const mode = record.mode === 'single' || record.mode === 'multiple' ? record.mode : null + if (!id || !title || !mode || !Array.isArray(record.options)) return null + + const options: InteractiveQuestionOption[] = [] + for (const optionValue of record.options) { + const option = normalizeOption(optionValue) + if (!option) return null + options.push(option) + } + if (options.length < 2 || options.length > 5) return null + + const optionIds = new Set(options.map((option) => option.id)) + if (optionIds.size !== options.length) return null + + return { + version: 1, + id, + title, + description: optionalString(record.description), + mode, + options, + allowCustom: record.allowCustom === true, + } +} + +function normalizeOption(value: unknown): InteractiveQuestionOption | null { + const record = asRecord(value) + if (!record) return null + + const id = requiredString(record.id) + const label = requiredString(record.label) + if (!id || !label) return null + + return { + id, + label, + summary: optionalString(record.summary), + details: optionalString(record.details), + } +} + +function asRecord(value: unknown): Record | null { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function requiredString(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed === '' ? null : trimmed +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed === '' ? undefined : trimmed +} diff --git a/web/src/views/chat/components/MessageBubble.vue b/web/src/views/chat/components/MessageBubble.vue index 769c0de..f294561 100644 --- a/web/src/views/chat/components/MessageBubble.vue +++ b/web/src/views/chat/components/MessageBubble.vue @@ -25,12 +25,24 @@ > … -
+ class="space-y-3" + > + +
+ + +
() -const emit = defineEmits<{ retry: [id: number]; save: [id: number] }>() -const rendered = computed(() => md.render(props.message.content)) +const props = defineProps<{ message: ChatMessage; savable?: boolean; answerDisabled?: boolean }>() +const emit = defineEmits<{ + retry: [id: number] + save: [id: number] + 'answer-question': [payload: InteractiveQuestionAnswer] +}>() +const parsed = computed(() => parseInteractiveMessage(props.message.content)) +const hasMarkdown = computed(() => parsed.value.markdown.trim() !== '') +const rendered = computed(() => md.render(parsed.value.markdown)) const canSave = computed( () => props.savable === true && props.message.role === 'assistant' && props.message.status === 'ok' && + parsed.value.question == null && props.message.content.trim() !== '', ) diff --git a/web/src/views/chat/components/QuestionCard.test.ts b/web/src/views/chat/components/QuestionCard.test.ts new file mode 100644 index 0000000..beaf762 --- /dev/null +++ b/web/src/views/chat/components/QuestionCard.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' + +import QuestionCard from './QuestionCard.vue' +import type { InteractiveQuestion } from '@/utils/interactiveMessage' + +function question(overrides: Partial = {}): InteractiveQuestion { + return { + version: 1, + id: 'target_user', + title: '这个需求主要面向谁?', + description: '选择后我会补全场景。', + mode: 'single', + options: [ + { id: 'admin', label: '后台管理员', summary: '负责配置和审核' }, + { id: 'user', label: '普通用户', details: '直接使用业务功能。' }, + ], + allowCustom: true, + ...overrides, + } +} + +describe('QuestionCard', () => { + it('单选提交选中的答案', async () => { + const wrapper = mount(QuestionCard, { props: { question: question() } }) + + await wrapper.get('[data-testid="question-option-admin"]').trigger('click') + await wrapper.get('[data-testid="question-submit"]').trigger('click') + + expect(wrapper.emitted('answer')?.[0][0]).toMatchObject({ + question: { id: 'target_user' }, + selectedOptionIds: ['admin'], + customText: '', + }) + }) + + it('多选可以提交多个答案', async () => { + const wrapper = mount(QuestionCard, { + props: { question: question({ mode: 'multiple', allowCustom: false }) }, + }) + + await wrapper.get('[data-testid="question-option-admin"]').trigger('click') + await wrapper.get('[data-testid="question-option-user"]').trigger('click') + await wrapper.get('[data-testid="question-submit"]').trigger('click') + + expect(wrapper.emitted('answer')?.[0][0]).toMatchObject({ + selectedOptionIds: ['admin', 'user'], + }) + }) + + it('禁用时不能选择或提交', async () => { + const wrapper = mount(QuestionCard, { props: { question: question(), disabled: true } }) + + await wrapper.get('[data-testid="question-option-admin"]').trigger('click') + await wrapper.get('[data-testid="question-submit"]').trigger('click') + + expect(wrapper.emitted('answer')).toBeUndefined() + }) +}) diff --git a/web/src/views/chat/components/QuestionCard.vue b/web/src/views/chat/components/QuestionCard.vue new file mode 100644 index 0000000..1b53196 --- /dev/null +++ b/web/src/views/chat/components/QuestionCard.vue @@ -0,0 +1,138 @@ + + + diff --git a/web/src/views/projects/components/RequirementChatPanel.vue b/web/src/views/projects/components/RequirementChatPanel.vue index bfe22a6..8e6bc77 100644 --- a/web/src/views/projects/components/RequirementChatPanel.vue +++ b/web/src/views/projects/components/RequirementChatPanel.vue @@ -73,8 +73,10 @@ :key="m.id" :message="m" :savable="!readonly" + :answer-disabled="readonly || session.isStreaming.value" @retry="session.retryMessage" @save="onSaveMessage" + @answer-question="onAnswerQuestion" />
m.id === id) if (!msg) return