You've already forked agentic-coding-workflow
支持让 AI 根据固定格式来提问,优化用户体验
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user