支持让 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
@@ -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>