支持让 AI 根据固定格式来提问,优化用户体验

This commit is contained in:
2026-06-10 21:31:27 +08:00
parent cdb22d4ddb
commit c0f15050d0
8 changed files with 500 additions and 8 deletions
@@ -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('上一阶段')
+28 -1
View File
@@ -14,6 +14,33 @@ export const PHASE_ROLE_PROMPTS: Record<ArtifactPhase, string> = {
评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`,
}
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<ArtifactPhase, ArtifactPhase | null> = {
planning: null,
@@ -28,7 +55,7 @@ export function buildRequirementSystemPrompt(
requirement: Pick<Requirement, 'number' | 'title' | 'description'>,
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}`)
+73
View File
@@ -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('还需要导出日志')
})
})
+161
View File
@@ -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<string, unknown> | null {
if (value == null || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
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
}
@@ -25,12 +25,24 @@
>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-else
class="prose prose-sm max-w-none"
v-html="rendered"
/>
class="space-y-3"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="hasMarkdown"
class="prose prose-sm max-w-none"
v-html="rendered"
/>
<!-- eslint-enable vue/no-v-html -->
<QuestionCard
v-if="parsed.question && message.role === 'assistant'"
:question="parsed.question"
:disabled="answerDisabled || message.status !== 'ok'"
@answer="emit('answer-question', $event)"
/>
</div>
<div
v-if="message.status === 'error'"
class="text-red-500 text-xs mt-1 flex items-center gap-2"
@@ -84,17 +96,26 @@ import { NCollapse, NCollapseItem, NButton } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { ChatMessage } from '@/api/chat'
import { parseInteractiveMessage, type InteractiveQuestionAnswer } from '@/utils/interactiveMessage'
import QuestionCard from './QuestionCard.vue'
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
// savable:仅需求阶段对话面板传 true,对成功的 assistant 回复显示「保存为产物」。
const props = defineProps<{ message: ChatMessage; savable?: boolean }>()
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() !== '',
)
</script>
@@ -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> = {}): 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()
})
})
@@ -0,0 +1,138 @@
<template>
<div class="rounded-md border border-blue-100 bg-white p-3 space-y-3">
<div>
<div class="text-sm font-medium text-gray-900">
{{ question.title }}
</div>
<p
v-if="question.description"
class="mt-1 text-xs text-gray-500 whitespace-pre-wrap"
>
{{ question.description }}
</p>
</div>
<div class="space-y-2">
<div
v-for="option in question.options"
:key="option.id"
class="rounded-md border bg-white"
:class="isSelected(option.id) ? 'border-blue-400 bg-blue-50' : 'border-gray-200'"
>
<button
type="button"
class="w-full text-left p-2 flex items-start gap-2 disabled:cursor-not-allowed"
:disabled="disabled"
:data-testid="`question-option-${option.id}`"
@click="toggleOption(option.id)"
>
<span
class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center border text-[10px]"
:class="[
question.mode === 'single' ? 'rounded-full' : 'rounded',
isSelected(option.id)
? 'border-blue-600 bg-blue-600 text-white'
: 'border-gray-300 text-transparent',
]"
>
</span>
<span class="min-w-0">
<span class="block text-sm font-medium text-gray-900">{{ option.label }}</span>
<span
v-if="option.summary"
class="block text-xs text-gray-500 whitespace-pre-wrap"
>
{{ option.summary }}
</span>
</span>
</button>
<NCollapse
v-if="option.details"
class="border-t border-gray-100 px-2"
>
<NCollapseItem
title="详情"
:name="option.id"
>
<p class="text-xs text-gray-600 whitespace-pre-wrap pb-2">
{{ option.details }}
</p>
</NCollapseItem>
</NCollapse>
</div>
</div>
<NInput
v-if="question.allowCustom"
v-model:value="customText"
type="textarea"
placeholder="补充说明(可选)"
:autosize="{ minRows: 1, maxRows: 3 }"
:disabled="disabled"
/>
<div class="flex justify-end">
<NButton
size="small"
type="primary"
:disabled="!canSubmit"
data-testid="question-submit"
@click="submit"
>
提交回答
</NButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NCollapse, NCollapseItem, NInput } from 'naive-ui'
import type { InteractiveQuestion, InteractiveQuestionAnswer } from '@/utils/interactiveMessage'
const props = defineProps<{ question: InteractiveQuestion; disabled?: boolean }>()
const emit = defineEmits<{ answer: [payload: InteractiveQuestionAnswer] }>()
const selectedOptionIds = ref<string[]>([])
const customText = ref('')
const canSubmit = computed(() => {
if (props.disabled === true) return false
if (selectedOptionIds.value.length > 0) return true
return props.question.allowCustom && customText.value.trim() !== ''
})
watch(
() => props.question.id,
() => {
selectedOptionIds.value = []
customText.value = ''
},
)
function isSelected(id: string) {
return selectedOptionIds.value.includes(id)
}
function toggleOption(id: string) {
if (props.disabled === true) return
if (props.question.mode === 'single') {
selectedOptionIds.value = [id]
return
}
selectedOptionIds.value = isSelected(id)
? selectedOptionIds.value.filter((selected) => selected !== id)
: [...selectedOptionIds.value, id]
}
function submit() {
if (!canSubmit.value) return
emit('answer', {
question: props.question,
selectedOptionIds: selectedOptionIds.value,
customText: customText.value,
})
}
</script>
@@ -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"
/>
</div>
<div
@@ -133,6 +135,10 @@ import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
import { useConversationSession } from '@/composables/useConversationSession'
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
import {
formatInteractiveQuestionAnswer,
type InteractiveQuestionAnswer,
} from '@/utils/interactiveMessage'
import MessageBubble from '@/views/chat/components/MessageBubble.vue'
import ModelSelector from '@/views/chat/components/ModelSelector.vue'
import SaveArtifactModal from './SaveArtifactModal.vue'
@@ -227,6 +233,11 @@ async function send() {
await session.sendMessage(body)
}
async function onAnswerQuestion(answer: InteractiveQuestionAnswer) {
if (props.readonly || session.isStreaming.value) return
await session.sendMessage(formatInteractiveQuestionAnswer(answer))
}
function onSaveMessage(id: number) {
const msg = session.messages.value.find((m) => m.id === id)
if (!msg) return