Files
agentic-coding-workflow/web/src/views/projects/components/RequirementChatPanel.vue
T
2026-06-10 18:27:08 +08:00

260 lines
7.2 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"
:message="m"
:savable="!readonly"
@retry="session.retryMessage"
@save="onSaveMessage"
/>
</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 { useConversationSession } from '@/composables/useConversationSession'
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
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 || '(未命名)'} · ${new Date(c.updated_at).toLocaleString('zh-CN', { hour12: false })}`,
value: c.id,
})),
)
async function loadConversations() {
conversations.value = await chatApi.listConversations({
requirement_id: props.requirement.id,
})
}
function openCreateForm() {
newSystemPrompt.value = buildRequirementSystemPrompt(
props.phase,
props.requirement,
props.prevArtifact,
)
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)
}
function onSaveMessage(id: number) {
const msg = session.messages.value.find((m) => m.id === id)
if (!msg) return
saveSource.value = msg
showSaveModal.value = true
}
// 流式输出时自动滚到底部(同 ChatDetailView 的策略)。
watch(
() => [session.messages.value.length, session.messages.value.at(-1)?.content?.length ?? 0],
() => {
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
})
},
)
// 阶段切换(父组件未卸载本组件时)重置会话面板状态。
watch(
() => props.phase,
() => {
session.closeCurrent()
showCreateForm.value = false
},
)
onMounted(loadConversations)
onUnmounted(() => session.closeCurrent())
</script>