You've already forked agentic-coding-workflow
252 lines
7.7 KiB
Vue
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>
|