Files
agentic-coding-workflow/web/src/views/projects/components/RequirementChatPanel.vue
T
2026-06-22 08:55:57 +08:00

252 lines
7.7 KiB
Vue

<template>
<NCard
:title="`AI 对话 · ${PHASE_TITLE[phase]}`"
class="flex flex-col h-full"
content-class="flex flex-col flex-1 min-h-0"
>
<!-- 会话选择条 -->
<div class="flex items-center gap-2 mb-3">
<NSelect
:value="session.currentConversation.value?.id ?? null"
:options="conversationOptions"
placeholder="选择历史会话"
size="small"
class="flex-1"
:disabled="creating"
@update:value="onSelectConversation"
/>
<NButton size="small" type="primary" secondary @click="openCreateForm"> 新建会话 </NButton>
</div>
<!-- 新建会话表单 -->
<div v-if="showCreateForm" class="space-y-2 mb-3 border rounded p-3">
<ModelSelector v-model:model-value="newModelId" />
<NInput
v-model:value="newSystemPrompt"
type="textarea"
placeholder="system prompt(已自动注入需求上下文,可修改)"
:autosize="{ minRows: 4, maxRows: 10 }"
/>
<NInput
v-model:value="newFirstMessage"
type="textarea"
placeholder="首条消息(创建后自动发送;留空则只建会话不发送)"
:autosize="{ minRows: 1, maxRows: 4 }"
/>
<div class="flex justify-end gap-2">
<NButton size="small" @click="showCreateForm = false"> 取消 </NButton>
<NButton
size="small"
type="primary"
:loading="creating"
:disabled="!newModelId"
@click="onCreateConversation"
>
创建并开始
</NButton>
</div>
</div>
<!-- 消息区 -->
<div
v-if="session.currentConversation.value"
ref="scrollEl"
class="flex-1 min-h-0 overflow-y-auto space-y-3 py-2"
>
<MessageBubble
v-for="m in session.messages.value"
:key="m.id"
v-memo="[m.id, m.content, m.status]"
:message="m"
:savable="!readonly"
:answer-disabled="readonly || session.isStreaming.value"
@retry="session.retryMessage"
@save="onSaveMessage"
@answer-question="onAnswerQuestion"
/>
</div>
<div v-else class="flex-1 flex items-center justify-center text-neutral-400 text-sm">
选择历史会话或新建会话开始与 AI 讨论本阶段内容
</div>
<!-- 输入区 -->
<div v-if="session.currentConversation.value" class="border-t pt-3 flex gap-2">
<NInput
v-model:value="draft"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="发送消息(Enter 发送,Shift+Enter 换行)"
:disabled="session.isStreaming.value"
@keydown.enter.exact.prevent="send"
/>
<NButton
v-if="!session.isStreaming.value"
type="primary"
:disabled="!draft.trim()"
@click="send"
>
发送
</NButton>
<NButton v-else type="warning" @click="session.cancelMessage"> 中断 </NButton>
</div>
<SaveArtifactModal
v-model:show="showSaveModal"
:slug="slug"
:number="requirement.number"
:phase="phase"
:source-message="saveSource"
@saved="emit('artifact-saved')"
/>
</NCard>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { NButton, NCard, NInput, NSelect } from 'naive-ui'
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
import { formatDateTime } from '@/utils/date'
import { useConversationSession } from '@/composables/useConversationSession'
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'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型设计',
auditing: '评审',
}
// 新建会话默认首条消息:system prompt 仅随请求下发不产生消息,必须有一条
// 用户消息才能触发 AI 开始输出,"创建并开始"靠它兑现。
const DEFAULT_FIRST_MESSAGE = '请开始本阶段工作。'
const props = defineProps<{
slug: string
requirement: Requirement
phase: ArtifactPhase
// 上一阶段产物最新版(planning 无),注入新会话 system prompt。
prevArtifact?: Artifact | null
readonly?: boolean
}>()
const emit = defineEmits<{ 'artifact-saved': [] }>()
// 自有会话实例,与全局 /chat 页完全隔离。
const session = useConversationSession()
const conversations = ref<Conversation[]>([])
const showCreateForm = ref(false)
const newModelId = ref<string | undefined>(undefined)
const newSystemPrompt = ref('')
const newFirstMessage = ref(DEFAULT_FIRST_MESSAGE)
const creating = ref(false)
const draft = ref('')
const scrollEl = ref<HTMLElement | null>(null)
const showSaveModal = ref(false)
const saveSource = ref<ChatMessage | null>(null)
const conversationOptions = computed(() =>
conversations.value.map((c) => ({
label: `${c.title || '(未命名)'} · ${formatDateTime(c.updated_at)}`,
value: c.id,
})),
)
async function loadConversations() {
conversations.value = await chatApi.listConversations({
requirement_id: props.requirement.id,
})
}
function openCreateForm() {
// system prompt 的角色模板 + 需求/产物上下文已由服务端组装(internal/brief 经
// composeHistory 注入 FK 上下文)。前端默认留空,仅在操作者想补充额外指令时手填。
newSystemPrompt.value = ''
newFirstMessage.value = DEFAULT_FIRST_MESSAGE
showCreateForm.value = true
}
async function onCreateConversation() {
if (!newModelId.value) return
creating.value = true
try {
const conv = await chatApi.createConversation({
model_id: newModelId.value,
system_prompt: newSystemPrompt.value,
project_id: props.requirement.project_id,
requirement_id: props.requirement.id,
})
showCreateForm.value = false
await loadConversations()
await session.openConversation(conv.id)
const first = newFirstMessage.value.trim()
if (first) await session.sendMessage(first)
} finally {
creating.value = false
}
}
async function onSelectConversation(id: string | null) {
if (!id) return
await session.openConversation(id)
}
async function send() {
if (!draft.value.trim() || session.isStreaming.value) return
const body = draft.value
draft.value = ''
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
saveSource.value = msg
showSaveModal.value = true
}
// 流式输出时自动滚到底部(同 ChatDetailView 的策略)。
let scrollPending = false
watch(
() => [session.messages.value.length, session.messages.value.at(-1)?.content?.length ?? 0],
() => {
if (scrollPending) return
scrollPending = true
requestAnimationFrame(() => {
scrollPending = false
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
})
})
},
)
// 阶段切换(父组件未卸载本组件时)重置会话面板状态。
watch(
() => props.phase,
() => {
session.closeCurrent()
draft.value = ''
showCreateForm.value = false
newSystemPrompt.value = ''
newFirstMessage.value = DEFAULT_FIRST_MESSAGE
},
)
onMounted(loadConversations)
onUnmounted(() => session.closeCurrent())
</script>