You've already forked agentic-coding-workflow
支持让 AI 根据固定格式来提问,优化用户体验
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import type { Artifact } from '@/api/projects'
|
import type { Artifact } from '@/api/projects'
|
||||||
import {
|
import {
|
||||||
buildRequirementSystemPrompt,
|
buildRequirementSystemPrompt,
|
||||||
|
INTERACTIVE_QUESTION_PROMPT,
|
||||||
PHASE_ROLE_PROMPTS,
|
PHASE_ROLE_PROMPTS,
|
||||||
PREV_ARTIFACT_PHASE,
|
PREV_ARTIFACT_PHASE,
|
||||||
} from './requirementPhasePrompts'
|
} from './requirementPhasePrompts'
|
||||||
@@ -26,6 +27,7 @@ describe('buildRequirementSystemPrompt', () => {
|
|||||||
it('planning:角色模板 + 需求信息,无上一阶段产物', () => {
|
it('planning:角色模板 + 需求信息,无上一阶段产物', () => {
|
||||||
const out = buildRequirementSystemPrompt('planning', req)
|
const out = buildRequirementSystemPrompt('planning', req)
|
||||||
expect(out).toContain(PHASE_ROLE_PROMPTS.planning)
|
expect(out).toContain(PHASE_ROLE_PROMPTS.planning)
|
||||||
|
expect(out).toContain(INTERACTIVE_QUESTION_PROMPT)
|
||||||
expect(out).toContain('需求 #7:导出报表')
|
expect(out).toContain('需求 #7:导出报表')
|
||||||
expect(out).toContain('支持导出 Excel')
|
expect(out).toContain('支持导出 Excel')
|
||||||
expect(out).not.toContain('上一阶段')
|
expect(out).not.toContain('上一阶段')
|
||||||
|
|||||||
@@ -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 无。
|
// 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。
|
||||||
export const PREV_ARTIFACT_PHASE: Record<ArtifactPhase, ArtifactPhase | null> = {
|
export const PREV_ARTIFACT_PHASE: Record<ArtifactPhase, ArtifactPhase | null> = {
|
||||||
planning: null,
|
planning: null,
|
||||||
@@ -28,7 +55,7 @@ export function buildRequirementSystemPrompt(
|
|||||||
requirement: Pick<Requirement, 'number' | 'title' | 'description'>,
|
requirement: Pick<Requirement, 'number' | 'title' | 'description'>,
|
||||||
prevArtifact?: Artifact | null,
|
prevArtifact?: Artifact | null,
|
||||||
): string {
|
): 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}`)
|
parts.push(`## 当前需求\n需求 #${requirement.number}:${requirement.title}`)
|
||||||
if (requirement.description) {
|
if (requirement.description) {
|
||||||
parts.push(`### 需求描述\n${requirement.description}`)
|
parts.push(`### 需求描述\n${requirement.description}`)
|
||||||
|
|||||||
@@ -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('还需要导出日志')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
</div>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="prose prose-sm max-w-none"
|
class="space-y-3"
|
||||||
v-html="rendered"
|
>
|
||||||
/>
|
<!-- 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
|
<div
|
||||||
v-if="message.status === 'error'"
|
v-if="message.status === 'error'"
|
||||||
class="text-red-500 text-xs mt-1 flex items-center gap-2"
|
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 MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
import type { ChatMessage } from '@/api/chat'
|
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 })
|
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||||
// savable:仅需求阶段对话面板传 true,对成功的 assistant 回复显示「保存为产物」。
|
// savable:仅需求阶段对话面板传 true,对成功的 assistant 回复显示「保存为产物」。
|
||||||
const props = defineProps<{ message: ChatMessage; savable?: boolean }>()
|
const props = defineProps<{ message: ChatMessage; savable?: boolean; answerDisabled?: boolean }>()
|
||||||
const emit = defineEmits<{ retry: [id: number]; save: [id: number] }>()
|
const emit = defineEmits<{
|
||||||
const rendered = computed(() => md.render(props.message.content))
|
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(
|
const canSave = computed(
|
||||||
() =>
|
() =>
|
||||||
props.savable === true &&
|
props.savable === true &&
|
||||||
props.message.role === 'assistant' &&
|
props.message.role === 'assistant' &&
|
||||||
props.message.status === 'ok' &&
|
props.message.status === 'ok' &&
|
||||||
|
parsed.value.question == null &&
|
||||||
props.message.content.trim() !== '',
|
props.message.content.trim() !== '',
|
||||||
)
|
)
|
||||||
</script>
|
</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"
|
:key="m.id"
|
||||||
:message="m"
|
:message="m"
|
||||||
:savable="!readonly"
|
:savable="!readonly"
|
||||||
|
:answer-disabled="readonly || session.isStreaming.value"
|
||||||
@retry="session.retryMessage"
|
@retry="session.retryMessage"
|
||||||
@save="onSaveMessage"
|
@save="onSaveMessage"
|
||||||
|
@answer-question="onAnswerQuestion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -133,6 +135,10 @@ import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
|
|||||||
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
|
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
|
||||||
import { useConversationSession } from '@/composables/useConversationSession'
|
import { useConversationSession } from '@/composables/useConversationSession'
|
||||||
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
|
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
|
||||||
|
import {
|
||||||
|
formatInteractiveQuestionAnswer,
|
||||||
|
type InteractiveQuestionAnswer,
|
||||||
|
} from '@/utils/interactiveMessage'
|
||||||
import MessageBubble from '@/views/chat/components/MessageBubble.vue'
|
import MessageBubble from '@/views/chat/components/MessageBubble.vue'
|
||||||
import ModelSelector from '@/views/chat/components/ModelSelector.vue'
|
import ModelSelector from '@/views/chat/components/ModelSelector.vue'
|
||||||
import SaveArtifactModal from './SaveArtifactModal.vue'
|
import SaveArtifactModal from './SaveArtifactModal.vue'
|
||||||
@@ -227,6 +233,11 @@ async function send() {
|
|||||||
await session.sendMessage(body)
|
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) {
|
function onSaveMessage(id: number) {
|
||||||
const msg = session.messages.value.find((m) => m.id === id)
|
const msg = session.messages.value.find((m) => m.id === id)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|||||||
Reference in New Issue
Block a user