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 {
|
||||
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('上一阶段')
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
<!-- 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
|
||||
|
||||
Reference in New Issue
Block a user