主流程

This commit is contained in:
2026-06-10 17:53:09 +08:00
parent c6f239f424
commit b20435c027
66 changed files with 2779 additions and 1181 deletions
+7 -2
View File
@@ -127,8 +127,13 @@ export const acpApi = {
request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }),
},
sessions: {
list: (all?: boolean) =>
request<AcpSession[]>('/api/v1/acp/sessions' + (all ? '?all=true' : '')),
list: (opts: { all?: boolean; requirement_id?: string } = {}) => {
const q = new URLSearchParams()
if (opts.all) q.set('all', 'true')
if (opts.requirement_id) q.set('requirement_id', opts.requirement_id)
const qs = q.toString()
return request<AcpSession[]>('/api/v1/acp/sessions' + (qs ? '?' + qs : ''))
},
get: (id: string) =>
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
create: (req: CreateSessionReq) =>
+3
View File
@@ -10,6 +10,7 @@ export interface Conversation {
project_id?: string | null
workspace_id?: string | null
issue_id?: string | null
requirement_id?: string | null
model_id: string
system_prompt: string
title?: string | null
@@ -72,6 +73,7 @@ export interface ListConversationsParams {
project_id?: string
workspace_id?: string
issue_id?: string
requirement_id?: string
q?: string
limit?: number
}
@@ -83,6 +85,7 @@ export interface CreateConversationBody {
project_id?: string
workspace_id?: string
issue_id?: string
requirement_id?: string
}
export interface UsageDailyItem {
+19 -10
View File
@@ -47,12 +47,17 @@ export interface Issue {
updated_at: string
}
export interface Prototype {
// ArtifactPhase 是允许产出文档型产物的三个阶段(与后端 ArtifactPhases 一致)。
export type ArtifactPhase = 'planning' | 'prototyping' | 'auditing'
export interface Artifact {
id: string
requirement_id: string
phase: ArtifactPhase
version: number
content: string
note: string
source_message_id?: number | null
created_by: string
created_at: string
}
@@ -126,19 +131,23 @@ export const projectsApi = {
reopenRequirement: (slug: string, number: number) =>
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
// ===== Prototype =====
listPrototypes: (slug: string, number: number) =>
request<{ prototypes: Prototype[] }>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`,
// ===== Artifact =====
listArtifacts: (slug: string, number: number, phase?: ArtifactPhase) =>
request<Artifact[]>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts${phase ? `?phase=${phase}` : ''}`,
),
createPrototype: (slug: string, number: number, body: { content: string; note?: string }) =>
request<Prototype>(`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, {
createArtifact: (
slug: string,
number: number,
body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number },
) =>
request<Artifact>(`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts`, {
method: 'POST',
body,
}),
getPrototype: (slug: string, number: number, version: number) =>
request<{ prototype: Prototype }>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes/${version}`,
getArtifact: (slug: string, number: number, phase: ArtifactPhase, version: number) =>
request<Artifact>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts/${phase}/${version}`,
),
// ===== Issue =====
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ChatStreamEvent } from './useChatStream'
const chatApiMock = vi.hoisted(() => ({
getConversation: vi.fn(),
listMessages: vi.fn(),
sendMessage: vi.fn(),
cancelMessage: vi.fn(),
retryMessage: vi.fn(),
}))
vi.mock('@/api/chat', () => ({ chatApi: chatApiMock }))
// startChatStream 的 mock:记录每次调用,暴露 emit 让测试驱动事件。
interface StreamInstance {
url: string
since: number
emit: (ev: ChatStreamEvent) => void
stopped: boolean
}
const streamInstances = vi.hoisted(() => [] as StreamInstance[])
vi.mock('@/composables/useChatStream', () => ({
startChatStream: vi.fn(
(
url: string,
since: number,
handlers: { onEvent: (ev: ChatStreamEvent) => void; onError: (e: Error) => void },
) => {
const inst: StreamInstance = {
url,
since,
emit: (ev) => handlers.onEvent(ev),
stopped: false,
}
streamInstances.push(inst)
return {
stop: () => {
inst.stopped = true
},
}
},
),
}))
import { useConversationSession } from './useConversationSession'
function conv(id: string) {
return { id, user_id: 'u1', model_id: 'm1', system_prompt: '', created_at: '', updated_at: '' }
}
function msg(id: number, status = 'ok', role = 'assistant') {
return {
id,
conversation_id: 'c1',
role,
content: `msg-${id}`,
status,
created_at: '',
updated_at: '',
}
}
beforeEach(() => {
streamInstances.length = 0
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useConversationSession', () => {
it('openConversation 将 DESC 消息排为 id 升序', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(3), msg(2), msg(1)],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(s.messages.value.map((m) => m.id)).toEqual([1, 2, 3])
expect(s.isStreaming.value).toBe(false)
})
it('openConversation 发现 pending 消息时续接流', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(2, 'pending'), msg(1)],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(s.streamingMessageId.value).toBe(2)
expect(streamInstances).toHaveLength(1)
expect(streamInstances[0].since).toBe(1)
})
it('sendMessage 追加两条占位消息并累积 text_delta 至 done', async () => {
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
chatApiMock.sendMessage.mockResolvedValue({
user_message_id: 10,
assistant_message_id: 11,
stream_url: '/stream',
})
const s = useConversationSession()
await s.openConversation('c1')
await s.sendMessage('hello')
expect(s.messages.value.map((m) => [m.id, m.role, m.status])).toEqual([
[10, 'user', 'ok'],
[11, 'assistant', 'pending'],
])
expect(s.isStreaming.value).toBe(true)
const stream = streamInstances[0]
stream.emit({ id: 1, event: 'text_delta', data: { text: 'foo' } })
stream.emit({ id: 2, event: 'text_delta', data: { text: 'bar' } })
stream.emit({ id: 3, event: 'done', data: {} })
const assistant = s.messages.value[1]
expect(assistant.content).toBe('foobar')
expect(assistant.status).toBe('ok')
expect(s.isStreaming.value).toBe(false)
})
it('error 事件标记消息失败并结束流式', async () => {
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
chatApiMock.sendMessage.mockResolvedValue({
user_message_id: 1,
assistant_message_id: 2,
stream_url: '/stream',
})
const s = useConversationSession()
await s.openConversation('c1')
await s.sendMessage('x')
streamInstances[0].emit({ id: 1, event: 'error', data: { message: 'boom' } })
expect(s.messages.value[1].status).toBe('error')
expect(s.messages.value[1].error_message).toBe('boom')
expect(s.isStreaming.value).toBe(false)
})
it('closeCurrent 终止流并清空状态', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(2, 'pending')],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(streamInstances[0].stopped).toBe(false)
s.closeCurrent()
expect(streamInstances[0].stopped).toBe(true)
expect(s.currentConversation.value).toBeNull()
expect(s.messages.value).toEqual([])
})
it('双实例互不干扰', async () => {
chatApiMock.getConversation
.mockResolvedValueOnce({ conversation: conv('c1'), messages: [msg(1)] })
.mockResolvedValueOnce({ conversation: conv('c2'), messages: [msg(5), msg(4)] })
const a = useConversationSession()
const b = useConversationSession()
await a.openConversation('c1')
await b.openConversation('c2')
expect(a.messages.value.map((m) => m.id)).toEqual([1])
expect(b.messages.value.map((m) => m.id)).toEqual([4, 5])
b.closeCurrent()
expect(a.currentConversation.value?.id).toBe('c1')
})
})
@@ -0,0 +1,223 @@
import { computed, ref } from 'vue'
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import {
startChatStream,
type ChatStreamEvent,
type ChatStreamHandle,
} from '@/composables/useChatStream'
interface UsagePayload {
PromptTokens?: number
CompletionTokens?: number
ThinkingTokens?: number
}
interface TextDeltaPayload {
text?: string
}
interface ErrorPayload {
code?: string
message?: string
}
interface MetaPayload {
id?: number
role?: string
status?: ChatMessage['status']
}
// useConversationSession 承载"单个会话的消息流"全部状态与操作(打开/分页/发送/
// 取消/重试/SSE 流式接收)。每次调用返回一个独立实例,互不串扰——全局 /chat 页
// 经 chat store 持有一份,需求详情页内嵌面板各自持有一份。
// 调用方负责在组件卸载时调用 closeCurrent() 终止流。
export function useConversationSession() {
const currentConversation = ref<Conversation | null>(null)
const messages = ref<ChatMessage[]>([])
const streamingMessageId = ref<number | null>(null)
const streamHandle = ref<ChatStreamHandle | null>(null)
const isStreaming = computed(() => streamingMessageId.value !== null)
async function openConversation(id: string) {
detachStream()
const detail = await chatApi.getConversation(id)
currentConversation.value = detail.conversation
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
const pending = detail.messages.find((m) => m.status === 'pending')
if (pending) {
streamingMessageId.value = pending.id
attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1)
}
}
async function loadOlderMessages() {
const conv = currentConversation.value
if (!conv) return
if (messages.value.length === 0) return
const beforeId = messages.value.reduce(
(min, m) => (m.id < min ? m.id : min),
messages.value[0].id,
)
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
if (older.length === 0) return
const existingIds = new Set(messages.value.map((m) => m.id))
const fresh = older.filter((m) => !existingIds.has(m.id))
if (fresh.length === 0) return
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
}
async function sendMessage(content: string, attachmentIds: string[] = []) {
if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
const conv = currentConversation.value
const resp = await chatApi.sendMessage(conv.id, {
content,
attachment_ids: attachmentIds,
})
const now = new Date().toISOString()
messages.value.push({
id: resp.user_message_id,
conversation_id: conv.id,
role: 'user',
content,
status: 'ok',
created_at: now,
updated_at: now,
})
messages.value.push({
id: resp.assistant_message_id,
conversation_id: conv.id,
role: 'assistant',
content: '',
status: 'pending',
created_at: now,
updated_at: now,
})
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id)
}
async function cancelMessage() {
const id = streamingMessageId.value
if (id == null) return
await chatApi.cancelMessage(id)
detachStream()
}
async function retryMessage(messageId: number) {
if (!currentConversation.value) return
const resp = await chatApi.retryMessage(messageId)
const idx = messages.value.findIndex((m) => m.id === messageId)
if (idx >= 0) {
const old = messages.value[idx]
messages.value[idx] = {
...old,
id: resp.assistant_message_id,
content: '',
thinking: null,
status: 'pending',
error_message: null,
}
}
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.assistant_message_id - 1)
}
function closeCurrent() {
detachStream()
currentConversation.value = null
messages.value = []
streamingMessageId.value = null
}
function attachStream(streamUrl: string, sinceMessageID: number) {
detachStream()
streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
onEvent: (ev) => applyStreamEvent(ev),
onClose: () => {
streamHandle.value = null
},
onError: (err) => {
console.error('chat stream error', err)
const id = streamingMessageId.value
if (id != null) {
const msg = messages.value.find((m) => m.id === id)
if (msg && msg.status === 'pending') {
msg.status = 'error'
msg.error_message = err.message
}
}
streamingMessageId.value = null
streamHandle.value = null
},
})
}
function detachStream() {
streamHandle.value?.stop()
streamHandle.value = null
}
function applyStreamEvent(ev: ChatStreamEvent) {
const targetId = streamingMessageId.value
if (targetId == null) return
const msg = messages.value.find((m) => m.id === targetId)
if (!msg) return
switch (ev.event) {
case 'message_meta': {
const d = ev.data as MetaPayload
if (d.status) msg.status = d.status
break
}
case 'text_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.content += d.text
break
}
case 'thinking_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.thinking = (msg.thinking ?? '') + d.text
break
}
case 'usage': {
const u = ev.data as UsagePayload
msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens
msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens
msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens
break
}
case 'done':
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
msg.status = 'ok'
streamingMessageId.value = null
break
case 'error': {
const d = ev.data as ErrorPayload
msg.status = 'error'
msg.error_message = d.message ?? d.code ?? 'stream error'
streamingMessageId.value = null
break
}
}
}
return {
currentConversation,
messages,
streamingMessageId,
isStreaming,
openConversation,
loadOlderMessages,
sendMessage,
cancelMessage,
retryMessage,
closeCurrent,
}
}
export type ConversationSession = ReturnType<typeof useConversationSession>
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import type { Artifact } from '@/api/projects'
import {
buildRequirementSystemPrompt,
PHASE_ROLE_PROMPTS,
PREV_ARTIFACT_PHASE,
} from './requirementPhasePrompts'
const req = { number: 7, title: '导出报表', description: '支持导出 Excel' }
function artifact(content: string): Artifact {
return {
id: 'a1',
requirement_id: 'r1',
phase: 'planning',
version: 2,
content,
note: '',
created_by: 'u1',
created_at: '',
}
}
describe('buildRequirementSystemPrompt', () => {
it('planning:角色模板 + 需求信息,无上一阶段产物', () => {
const out = buildRequirementSystemPrompt('planning', req)
expect(out).toContain(PHASE_ROLE_PROMPTS.planning)
expect(out).toContain('需求 #7:导出报表')
expect(out).toContain('支持导出 Excel')
expect(out).not.toContain('上一阶段')
})
it('prototyping:注入上一阶段(planning)产物', () => {
const out = buildRequirementSystemPrompt('prototyping', req, artifact('# 规划文档内容'))
expect(out).toContain(PHASE_ROLE_PROMPTS.prototyping)
expect(out).toContain('上一阶段(planning)产物 v2')
expect(out).toContain('# 规划文档内容')
})
it('描述为空时不渲染需求描述段', () => {
const out = buildRequirementSystemPrompt('planning', { ...req, description: '' })
expect(out).not.toContain('需求描述')
})
it('超长产物内容截断并标注', () => {
const long = 'x'.repeat(10000)
const out = buildRequirementSystemPrompt('auditing', req, artifact(long))
expect(out).toContain('已截断')
expect(out.length).toBeLessThan(10000 + PHASE_ROLE_PROMPTS.auditing.length)
})
it('PREV_ARTIFACT_PHASE 链:planning←无、prototyping←planning、auditing←prototyping', () => {
expect(PREV_ARTIFACT_PHASE.planning).toBeNull()
expect(PREV_ARTIFACT_PHASE.prototyping).toBe('planning')
expect(PREV_ARTIFACT_PHASE.auditing).toBe('prototyping')
})
})
@@ -0,0 +1,44 @@
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
// 上一阶段产物注入 system prompt 的最大字符数,超出截断并标注。
const MAX_PREV_ARTIFACT_CHARS = 8000
// 三个 AI 对话阶段的角色模板。创建会话时与需求上下文拼接为 system prompt,
// 组装结果在创建表单中可见可改(前端组装方案,零后端耦合)。
export const PHASE_ROLE_PROMPTS: Record<ArtifactPhase, string> = {
planning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。
讨论时主动提问补全缺失信息;输出规划文档时包含:背景与目标、范围(含不做什么)、用户场景、功能拆解、风险与依赖、验收标准。`,
prototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。
内容应包含:页面/界面结构、交互流程、状态与边界情况、数据展示与输入约束;可用文字线框、列表与表格描述布局。`,
auditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。
评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`,
}
// 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。
export const PREV_ARTIFACT_PHASE: Record<ArtifactPhase, ArtifactPhase | null> = {
planning: null,
prototyping: 'planning',
auditing: 'prototyping',
}
// buildRequirementSystemPrompt 组装阶段会话的 system prompt:
// 角色模板 + 需求标题/描述 + 上一阶段产物最新版(如有,超长截断)。
export function buildRequirementSystemPrompt(
phase: ArtifactPhase,
requirement: Pick<Requirement, 'number' | 'title' | 'description'>,
prevArtifact?: Artifact | null,
): string {
const parts: string[] = [PHASE_ROLE_PROMPTS[phase]]
parts.push(`## 当前需求\n需求 #${requirement.number}${requirement.title}`)
if (requirement.description) {
parts.push(`### 需求描述\n${requirement.description}`)
}
if (prevArtifact) {
let content = prevArtifact.content
if (content.length > MAX_PREV_ARTIFACT_CHARS) {
content = content.slice(0, MAX_PREV_ARTIFACT_CHARS) + '\n\n……(内容过长,已截断)'
}
parts.push(`## 上一阶段(${prevArtifact.phase})产物 v${prevArtifact.version}\n${content}`)
}
return parts.join('\n\n')
}
+2 -2
View File
@@ -60,11 +60,11 @@ export const useAcpStore = defineStore('acp', () => {
return acpApi.agentKinds.get(id)
}
async function listSessions(all = false) {
async function listSessions(all = false, requirementId?: string) {
loading.value = true
error.value = null
try {
sessions.value = await acpApi.sessions.list(all)
sessions.value = await acpApi.sessions.list({ all, requirement_id: requirementId })
} catch (e) {
error.value = e instanceof Error ? e.message : 'failed'
} finally {
+19 -217
View File
@@ -1,239 +1,41 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import {
chatApi,
type ChatMessage,
type Conversation,
type ListConversationsParams,
} from '@/api/chat'
import {
startChatStream,
type ChatStreamEvent,
type ChatStreamHandle,
} from '@/composables/useChatStream'
interface UsagePayload {
PromptTokens?: number
CompletionTokens?: number
ThinkingTokens?: number
}
interface TextDeltaPayload {
text?: string
}
interface ErrorPayload {
code?: string
message?: string
}
interface MetaPayload {
id?: number
role?: string
status?: ChatMessage['status']
}
import { chatApi, type Conversation, type ListConversationsParams } from '@/api/chat'
import { useConversationSession } from '@/composables/useConversationSession'
// chat store = 全局 /chat 页那一份会话实例 + 会话列表。
// "单会话消息流"逻辑在 useConversationSession 中(可多实例,详情页内嵌面板自建),
// 这里持有一份并原样转发,保持对外 API 不变(ChatDetailView 零改动)。
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>([])
const currentConversation = ref<Conversation | null>(null)
const messages = ref<ChatMessage[]>([])
const streamingMessageId = ref<number | null>(null)
const streamHandle = ref<ChatStreamHandle | null>(null)
const isStreaming = computed(() => streamingMessageId.value !== null)
const session = useConversationSession()
async function loadConversations(filter: ListConversationsParams = {}) {
conversations.value = await chatApi.listConversations(filter)
}
async function openConversation(id: string) {
detachStream()
const detail = await chatApi.getConversation(id)
currentConversation.value = detail.conversation
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
const pending = detail.messages.find((m) => m.status === 'pending')
if (pending) {
streamingMessageId.value = pending.id
attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1)
}
}
async function loadOlderMessages() {
const conv = currentConversation.value
if (!conv) return
if (messages.value.length === 0) return
const beforeId = messages.value.reduce(
(min, m) => (m.id < min ? m.id : min),
messages.value[0].id,
)
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
if (older.length === 0) return
const existingIds = new Set(messages.value.map((m) => m.id))
const fresh = older.filter((m) => !existingIds.has(m.id))
if (fresh.length === 0) return
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
}
async function sendMessage(content: string, attachmentIds: string[] = []) {
if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
const conv = currentConversation.value
const resp = await chatApi.sendMessage(conv.id, {
content,
attachment_ids: attachmentIds,
})
const now = new Date().toISOString()
messages.value.push({
id: resp.user_message_id,
conversation_id: conv.id,
role: 'user',
content,
status: 'ok',
created_at: now,
updated_at: now,
})
messages.value.push({
id: resp.assistant_message_id,
conversation_id: conv.id,
role: 'assistant',
content: '',
status: 'pending',
created_at: now,
updated_at: now,
})
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id)
}
async function cancelMessage() {
const id = streamingMessageId.value
if (id == null) return
await chatApi.cancelMessage(id)
detachStream()
}
async function retryMessage(messageId: number) {
if (!currentConversation.value) return
const resp = await chatApi.retryMessage(messageId)
const idx = messages.value.findIndex((m) => m.id === messageId)
if (idx >= 0) {
const old = messages.value[idx]
messages.value[idx] = {
...old,
id: resp.assistant_message_id,
content: '',
thinking: null,
status: 'pending',
error_message: null,
}
}
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.assistant_message_id - 1)
}
async function deleteConversation(id: string) {
await chatApi.deleteConversation(id)
conversations.value = conversations.value.filter((c) => c.id !== id)
if (currentConversation.value?.id === id) {
closeCurrent()
}
}
function closeCurrent() {
detachStream()
currentConversation.value = null
messages.value = []
streamingMessageId.value = null
}
function attachStream(streamUrl: string, sinceMessageID: number) {
detachStream()
streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
onEvent: (ev) => applyStreamEvent(ev),
onClose: () => {
streamHandle.value = null
},
onError: (err) => {
console.error('chat stream error', err)
const id = streamingMessageId.value
if (id != null) {
const msg = messages.value.find((m) => m.id === id)
if (msg && msg.status === 'pending') {
msg.status = 'error'
msg.error_message = err.message
}
}
streamingMessageId.value = null
streamHandle.value = null
},
})
}
function detachStream() {
streamHandle.value?.stop()
streamHandle.value = null
}
function applyStreamEvent(ev: ChatStreamEvent) {
const targetId = streamingMessageId.value
if (targetId == null) return
const msg = messages.value.find((m) => m.id === targetId)
if (!msg) return
switch (ev.event) {
case 'message_meta': {
const d = ev.data as MetaPayload
if (d.status) msg.status = d.status
break
}
case 'text_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.content += d.text
break
}
case 'thinking_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.thinking = (msg.thinking ?? '') + d.text
break
}
case 'usage': {
const u = ev.data as UsagePayload
msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens
msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens
msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens
break
}
case 'done':
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
msg.status = 'ok'
streamingMessageId.value = null
break
case 'error': {
const d = ev.data as ErrorPayload
msg.status = 'error'
msg.error_message = d.message ?? d.code ?? 'stream error'
streamingMessageId.value = null
break
}
if (session.currentConversation.value?.id === id) {
session.closeCurrent()
}
}
return {
conversations,
currentConversation,
messages,
streamingMessageId,
isStreaming,
currentConversation: session.currentConversation,
messages: session.messages,
streamingMessageId: session.streamingMessageId,
isStreaming: session.isStreaming,
loadConversations,
openConversation,
loadOlderMessages,
closeCurrent,
sendMessage,
cancelMessage,
retryMessage,
openConversation: session.openConversation,
loadOlderMessages: session.loadOlderMessages,
closeCurrent: session.closeCurrent,
sendMessage: session.sendMessage,
cancelMessage: session.cancelMessage,
retryMessage: session.retryMessage,
deleteConversation,
}
})
+21 -17
View File
@@ -2,8 +2,9 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
projectsApi,
type Artifact,
type ArtifactPhase,
type Phase,
type Prototype,
type Requirement,
type Status,
} from '@/api/projects'
@@ -51,26 +52,29 @@ export const useRequirementsStore = defineStore('requirements', () => {
await refresh(forSlug)
}
async function listPrototypes(forSlug: string, number: number): Promise<Prototype[]> {
const out = await projectsApi.listPrototypes(forSlug, number)
return out.prototypes
}
async function createPrototype(
async function listArtifacts(
forSlug: string,
number: number,
body: { content: string; note?: string },
): Promise<Prototype> {
return await projectsApi.createPrototype(forSlug, number, body)
phase?: ArtifactPhase,
): Promise<Artifact[]> {
return await projectsApi.listArtifacts(forSlug, number, phase)
}
async function getPrototype(
async function createArtifact(
forSlug: string,
number: number,
body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number },
): Promise<Artifact> {
return await projectsApi.createArtifact(forSlug, number, body)
}
async function getArtifact(
forSlug: string,
number: number,
phase: ArtifactPhase,
version: number,
): Promise<Prototype> {
const out = await projectsApi.getPrototype(forSlug, number, version)
return out.prototype
): Promise<Artifact> {
return await projectsApi.getArtifact(forSlug, number, phase, version)
}
return {
@@ -84,8 +88,8 @@ export const useRequirementsStore = defineStore('requirements', () => {
changePhase,
close,
reopen,
listPrototypes,
createPrototype,
getPrototype,
listArtifacts,
createArtifact,
getArtifact,
}
})
+16 -1
View File
@@ -46,8 +46,13 @@ vi.mock('@/api/workspaces', () => ({
},
}))
const routeMock = vi.hoisted(() => ({
params: { slug: 'p', wsSlug: 'w' },
query: {} as Record<string, string>,
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { slug: 'p', wsSlug: 'w' } }),
useRoute: () => routeMock,
useRouter: () => ({ push: vi.fn() }),
}))
@@ -55,6 +60,7 @@ import AcpSessionCreateView from './AcpSessionCreateView.vue'
beforeEach(() => {
setActivePinia(createPinia())
routeMock.query = {}
})
describe('AcpSessionCreateView', () => {
@@ -63,4 +69,13 @@ describe('AcpSessionCreateView', () => {
await new Promise((r) => setTimeout(r, 20))
expect(wrapper.html()).toContain('New ACP Session')
})
it('prefills requirement/issue number from query', async () => {
routeMock.query = { requirement_number: '7', issue_number: '42' }
const wrapper = mount(AcpSessionCreateView)
await new Promise((r) => setTimeout(r, 20))
const inputs = wrapper.findAll('input[type="number"]')
expect((inputs[0].element as HTMLInputElement).value).toBe('42')
expect((inputs[1].element as HTMLInputElement).value).toBe('7')
})
})
@@ -20,6 +20,13 @@ const submitting = ref(false)
const errorMsg = ref<string | null>(null)
onMounted(async () => {
// 来自需求详情页等入口的预填(?requirement_number= / ?issue_number=)。
if (typeof route.query.requirement_number === 'string') {
form.value.requirement_number = route.query.requirement_number
}
if (typeof route.query.issue_number === 'string') {
form.value.issue_number = route.query.issue_number
}
await acpStore.listAgentKinds(false)
if (wsStore.list.length === 0) {
await wsStore.fetchByProject(route.params.slug as string)
@@ -62,6 +62,18 @@
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
</div>
<div
v-if="canSave"
class="mt-2"
>
<NButton
size="tiny"
secondary
@click="emit('save', message.id)"
>
保存为产物
</NButton>
</div>
</div>
</div>
</template>
@@ -74,7 +86,15 @@ import MarkdownIt from 'markdown-it'
import type { ChatMessage } from '@/api/chat'
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const props = defineProps<{ message: ChatMessage }>()
const emit = defineEmits<{ retry: [id: number] }>()
// 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 canSave = computed(
() =>
props.savable === true &&
props.message.role === 'assistant' &&
props.message.status === 'ok' &&
props.message.content.trim() !== '',
)
</script>
+120 -185
View File
@@ -47,7 +47,7 @@
</div>
</div>
<!-- Phase flow bar -->
<!-- Phase flow bar阶段跳转始终由用户确认触发 -->
<div class="flex items-center gap-1">
<template
v-for="(phase, idx) in PHASES"
@@ -69,157 +69,105 @@
</template>
</div>
<!-- Description -->
<NCard title="描述">
<p
v-if="data.requirement.description"
class="text-gray-700 whitespace-pre-wrap"
>
{{ data.requirement.description }}
</p>
<p
v-else
class="text-gray-400 text-sm"
>
暂无描述
</p>
</NCard>
<!-- Prototypes -->
<NCard title="原型">
<div class="flex gap-4">
<!-- Version list -->
<div class="w-56 shrink-0">
<NList
v-if="prototypes.length > 0"
:show-divider="false"
>
<NListItem
v-for="p in prototypes"
:key="p.id"
>
<NThing
class="cursor-pointer"
@click="onSelectPrototype(p.version)"
>
<template #header>
<span
class="text-sm font-medium"
:class="selectedVersion === p.version ? 'text-blue-600' : ''"
>v{{ p.version }}</span>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(p.created_at).toLocaleString() }}
</span>
</template>
<span
v-if="p.note"
class="text-xs text-gray-500"
>{{ p.note }}</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无原型版本"
size="small"
/>
</div>
<!-- Selected content -->
<div class="flex-1 min-w-0">
<!-- 左右布局=需求信息+当前阶段产物=按阶段切换的 AI 工作面板 -->
<div class="flex gap-6 items-start">
<div class="w-[45%] shrink-0 space-y-6">
<!-- Description -->
<NCard title="描述">
<p
v-if="selectedPrototype"
class="text-gray-700 whitespace-pre-wrap text-sm"
v-if="data.requirement.description"
class="text-gray-700 whitespace-pre-wrap"
>
{{ selectedPrototype.content }}
{{ data.requirement.description }}
</p>
<p
v-else
class="text-gray-400 text-sm"
>
选择左侧版本以查看内容
暂无描述
</p>
</div>
</NCard>
<!-- 当前阶段产物 AI 对话三阶段显示 -->
<ArtifactPanel
v-if="chatPhase"
ref="artifactPanelRef"
:slug="slug"
:number="number"
:phase="chatPhase"
:readonly="data.requirement.status !== 'open'"
/>
<!-- Workspace card -->
<NCard title="工作区">
<NSelect
:value="data.requirement.workspace_id"
:options="workspaceOptions"
placeholder="未关联"
clearable
@update:value="onWorkspaceChange"
/>
</NCard>
<!-- Linked Issues -->
<NCard title="关联 Issue">
<NList
v-if="data.issues.length > 0"
:show-divider="false"
>
<NListItem
v-for="issue in data.issues"
:key="issue.id"
>
<NThing>
<template #header>
<span class="font-mono text-xs text-gray-400 mr-2">#{{ issue.number }}</span>
<span class="text-sm font-medium">{{ issue.title }}</span>
</template>
<template #header-extra>
<NTag
:type="issue.status === 'open' ? 'success' : 'default'"
size="tiny"
>
{{ issue.status === 'open' ? '开放' : '关闭' }}
</NTag>
</template>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无关联 Issue"
/>
</NCard>
</div>
<!-- New version form -->
<NDivider />
<div
v-if="data.requirement.status === 'open'"
class="space-y-2"
>
<NInput
v-model:value="newContent"
type="textarea"
placeholder="原型内容"
:autosize="{ minRows: 4, maxRows: 12 }"
<!-- 右栏v-if 切换保证阶段变化时旧面板卸载自动断流 -->
<div class="flex-1 min-w-0">
<RequirementChatPanel
v-if="chatPhase"
:slug="slug"
:requirement="data.requirement"
:phase="chatPhase"
:prev-artifact="prevArtifact"
:readonly="data.requirement.status !== 'open'"
class="min-h-[70vh]"
@artifact-saved="onArtifactSaved"
/>
<NInput
v-model:value="newNote"
placeholder="备注(可选)"
<RequirementAcpPanel
v-else-if="
data.requirement.phase === 'implementing' || data.requirement.phase === 'reviewing'
"
:slug="slug"
:requirement="data.requirement"
/>
<RequirementSummaryPanel
v-else
:slug="slug"
:number="number"
/>
<NButton
type="primary"
size="small"
:loading="submitting"
:disabled="!newContent.trim()"
@click="onSubmitPrototype"
>
提交新版本
</NButton>
</div>
<p
v-else
class="text-gray-400 text-sm"
>
需求已关闭无法提交新版本
</p>
</NCard>
<!-- Workspace card -->
<NCard title="工作区">
<NSelect
:value="data.requirement.workspace_id"
:options="workspaceOptions"
placeholder="未关联"
clearable
@update:value="onWorkspaceChange"
/>
</NCard>
<!-- Linked Issues -->
<NCard title="关联 Issue">
<NList
v-if="data.issues.length > 0"
:show-divider="false"
>
<NListItem
v-for="issue in data.issues"
:key="issue.id"
>
<NThing>
<template #header>
<span class="font-mono text-xs text-gray-400 mr-2">#{{ issue.number }}</span>
<span class="text-sm font-medium">{{ issue.title }}</span>
</template>
<template #header-extra>
<NTag
:type="issue.status === 'open' ? 'success' : 'default'"
size="tiny"
>
{{ issue.status === 'open' ? '开放' : '关闭' }}
</NTag>
</template>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无关联 Issue"
/>
</NCard>
</div>
</div>
<div
@@ -233,7 +181,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDialog } from 'naive-ui'
import {
@@ -246,26 +194,31 @@ import {
NListItem,
NThing,
NEmpty,
NInput,
NDivider,
} from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { projectsApi } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
import { useWorkspacesStore } from '@/stores/workspaces'
import type { Phase, Prototype, RequirementDetail } from '@/api/projects'
import { PREV_ARTIFACT_PHASE } from '@/constants/requirementPhasePrompts'
import ArtifactPanel from './components/ArtifactPanel.vue'
import RequirementChatPanel from './components/RequirementChatPanel.vue'
import RequirementAcpPanel from './components/RequirementAcpPanel.vue'
import RequirementSummaryPanel from './components/RequirementSummaryPanel.vue'
import type { Artifact, ArtifactPhase, Phase, RequirementDetail } from '@/api/projects'
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
const PHASE_LABELS: Record<Phase, string> = {
planning: '规划中',
prototyping: '原型设计',
auditing: '审中',
auditing: '审中',
implementing: '实施中',
reviewing: '评审中',
reviewing: '验收中',
done: '已完成',
}
const CHAT_PHASES: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
const route = useRoute()
const router = useRouter()
const dialog = useDialog()
@@ -278,20 +231,18 @@ const number = Number(route.params.number)
const loading = ref(false)
const toggling = ref(false)
const data = ref<RequirementDetail | null>(null)
const prototypes = ref<Prototype[]>([])
const selectedVersion = ref<number | null>(null)
const newContent = ref('')
const newNote = ref('')
const submitting = ref(false)
const artifactPanelRef = ref<InstanceType<typeof ArtifactPanel> | null>(null)
const prevArtifact = ref<Artifact | null>(null)
const workspaceOptions = computed(() =>
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
)
const selectedPrototype = computed(() =>
prototypes.value.find((p) => p.version === selectedVersion.value) ?? null,
)
// 当前阶段属于 AI 对话三阶段时返回该阶段,否则 null。
const chatPhase = computed<ArtifactPhase | null>(() => {
const p = data.value?.requirement.phase
return p && (CHAT_PHASES as Phase[]).includes(p) ? (p as ArtifactPhase) : null
})
async function fetchData() {
loading.value = true
@@ -301,44 +252,25 @@ async function fetchData() {
wsStore.fetchByProject(slug),
])
data.value = detail
await refreshPrototypes()
} finally {
loading.value = false
}
}
async function refreshPrototypes() {
prototypes.value = await store.listPrototypes(slug, number)
// 默认选中最新版本
if (prototypes.value.length > 0) {
const latest = prototypes.value[prototypes.value.length - 1]
if (selectedVersion.value === null || !selectedPrototype.value) {
selectedVersion.value = latest.version
}
} else {
selectedVersion.value = null
}
// 上一阶段产物最新版:注入新会话 system prompt(planning 无上一阶段)。
async function loadPrevArtifact() {
prevArtifact.value = null
if (!chatPhase.value) return
const prevPhase = PREV_ARTIFACT_PHASE[chatPhase.value]
if (!prevPhase) return
const list = await store.listArtifacts(slug, number, prevPhase)
prevArtifact.value = list.length > 0 ? list[list.length - 1] : null
}
function onSelectPrototype(version: number) {
selectedVersion.value = version
}
watch(chatPhase, loadPrevArtifact)
async function onSubmitPrototype() {
if (!newContent.value.trim()) return
submitting.value = true
try {
const created = await store.createPrototype(slug, number, {
content: newContent.value,
note: newNote.value || undefined,
})
newContent.value = ''
newNote.value = ''
selectedVersion.value = created.version
await refreshPrototypes()
} finally {
submitting.value = false
}
function onArtifactSaved() {
artifactPanelRef.value?.refresh()
}
async function onWorkspaceChange(v: string | null) {
@@ -384,5 +316,8 @@ async function onReopen() {
}
}
onMounted(fetchData)
onMounted(async () => {
await fetchData()
await loadPrevArtifact()
})
</script>
@@ -0,0 +1,195 @@
<template>
<NCard :title="`${PHASE_TITLE[phase]}产物`">
<div class="flex gap-4">
<!-- 版本列表 -->
<div class="w-44 shrink-0">
<NList
v-if="artifacts.length > 0"
:show-divider="false"
>
<NListItem
v-for="a in artifacts"
:key="a.id"
>
<NThing
class="cursor-pointer"
@click="selectedVersion = a.version"
>
<template #header>
<span
class="text-sm font-medium"
:class="selectedVersion === a.version ? 'text-blue-600' : ''"
>v{{ a.version }}</span>
<NTag
v-if="a.source_message_id"
size="tiny"
class="ml-1"
>
AI
</NTag>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(a.created_at).toLocaleDateString() }}
</span>
</template>
<span
v-if="a.note"
class="text-xs text-gray-500"
>{{ a.note }}</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无产物版本"
size="small"
/>
</div>
<!-- 选中版本内容markdown 渲染 -->
<div class="flex-1 min-w-0">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-if="selectedArtifact"
class="prose prose-sm max-w-none"
v-html="rendered"
/>
<p
v-else
class="text-gray-400 text-sm"
>
选择左侧版本以查看内容
</p>
</div>
</div>
<!-- 手动提交新版本 -->
<NDivider />
<div
v-if="!readonly"
class="space-y-2"
>
<NInput
v-model:value="newContent"
type="textarea"
placeholder="产物内容(Markdown)"
:autosize="{ minRows: 3, maxRows: 10 }"
/>
<NInput
v-model:value="newNote"
placeholder="备注(可选)"
/>
<NButton
type="primary"
size="small"
:loading="submitting"
:disabled="!newContent.trim()"
@click="onSubmit"
>
提交新版本
</NButton>
</div>
<p
v-else
class="text-gray-400 text-sm"
>
需求已关闭无法提交新版本
</p>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
NButton,
NCard,
NDivider,
NEmpty,
NInput,
NList,
NListItem,
NTag,
NThing,
} from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { Artifact, ArtifactPhase } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型',
auditing: '评审',
}
const props = defineProps<{
slug: string
number: number
phase: ArtifactPhase
readonly?: boolean
}>()
const store = useRequirementsStore()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const artifacts = ref<Artifact[]>([])
const selectedVersion = ref<number | null>(null)
const newContent = ref('')
const newNote = ref('')
const submitting = ref(false)
const selectedArtifact = computed(
() => artifacts.value.find((a) => a.version === selectedVersion.value) ?? null,
)
const rendered = computed(() =>
selectedArtifact.value ? md.render(selectedArtifact.value.content) : '',
)
// latest 暴露给父组件:创建会话时把当前阶段或上一阶段最新版注入 system prompt。
const latest = computed(() =>
artifacts.value.length > 0 ? artifacts.value[artifacts.value.length - 1] : null,
)
async function refresh() {
artifacts.value = await store.listArtifacts(props.slug, props.number, props.phase)
if (artifacts.value.length > 0) {
if (selectedVersion.value === null || !selectedArtifact.value) {
selectedVersion.value = artifacts.value[artifacts.value.length - 1].version
}
} else {
selectedVersion.value = null
}
}
async function onSubmit() {
if (!newContent.value.trim()) return
submitting.value = true
try {
const created = await store.createArtifact(props.slug, props.number, {
phase: props.phase,
content: newContent.value,
note: newNote.value || undefined,
})
newContent.value = ''
newNote.value = ''
selectedVersion.value = created.version
await refresh()
} finally {
submitting.value = false
}
}
// 阶段切换时重载该阶段产物(选中状态重置)
watch(
() => props.phase,
() => {
selectedVersion.value = null
refresh()
},
)
onMounted(refresh)
defineExpose({ refresh, latest })
</script>
@@ -0,0 +1,123 @@
<template>
<NCard :title="`ACP 开发会话 · ${phaseLabel}`">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">关联本需求的 agent 会话</span>
<div class="flex items-center gap-2">
<NButton
size="small"
:loading="loading"
@click="refresh"
>
刷新
</NButton>
<NTooltip :disabled="!!workspaceSlug">
<template #trigger>
<NButton
size="small"
type="primary"
:disabled="!workspaceSlug"
@click="goCreate"
>
发起{{ phaseLabel }}会话
</NButton>
</template>
需先在左侧工作区卡片关联 workspace
</NTooltip>
</div>
</div>
<NList
v-if="sessions.length > 0"
:show-divider="false"
>
<NListItem
v-for="s in sessions"
:key="s.id"
>
<NThing
class="cursor-pointer"
@click="goDetail(s)"
>
<template #header>
<span class="font-mono text-sm">{{ s.branch }}</span>
</template>
<template #header-extra>
<SessionStatusBadge :status="s.status" />
</template>
<span class="text-xs text-gray-400">
{{ new Date(s.started_at).toLocaleString('zh-CN', { hour12: false }) }}
<template v-if="s.last_error"> · {{ s.last_error }}</template>
</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无关联会话"
size="small"
/>
</div>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NCard, NEmpty, NList, NListItem, NThing, NTooltip } from 'naive-ui'
import { acpApi, type AcpSession } from '@/api/acp'
import type { Requirement } from '@/api/projects'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
import { useWorkspacesStore } from '@/stores/workspaces'
const props = defineProps<{
slug: string
requirement: Requirement
}>()
const router = useRouter()
const wsStore = useWorkspacesStore()
const sessions = ref<AcpSession[]>([])
const loading = ref(false)
const phaseLabel = computed(() =>
props.requirement.phase === 'reviewing' ? '验收' : '实施',
)
// requirement.workspace_id → workspace slug(路由需要 wsSlug)。
const workspaceSlug = computed(() => {
const wsID = props.requirement.workspace_id
if (!wsID) return null
return wsStore.list.find((w) => w.id === wsID)?.slug ?? null
})
async function refresh() {
loading.value = true
try {
sessions.value = await acpApi.sessions.list({ requirement_id: props.requirement.id })
} finally {
loading.value = false
}
}
function goCreate() {
if (!workspaceSlug.value) return
router.push({
name: 'acp-sessions-new',
params: { slug: props.slug, wsSlug: workspaceSlug.value },
query: { requirement_number: String(props.requirement.number) },
})
}
function goDetail(s: AcpSession) {
if (!workspaceSlug.value) return
router.push({
name: 'acp-sessions-detail',
params: { slug: props.slug, wsSlug: workspaceSlug.value, sessionId: s.id },
})
}
onMounted(refresh)
</script>
@@ -0,0 +1,245 @@
<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 }"
/>
<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: '评审',
}
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 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,
)
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)
} 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>
@@ -0,0 +1,112 @@
<template>
<NCard title="产物汇总">
<NSpin :show="loading">
<div
v-if="grouped.length > 0"
class="space-y-4"
>
<div
v-for="g in grouped"
:key="g.phase"
>
<h3 class="text-sm font-semibold mb-2">
{{ PHASE_TITLE[g.phase] }}{{ g.items.length }} 个版本
</h3>
<NList :show-divider="false">
<NListItem
v-for="a in g.items"
:key="a.id"
>
<NThing
class="cursor-pointer"
@click="selected = selected?.id === a.id ? null : a"
>
<template #header>
<span
class="text-sm font-medium"
:class="selected?.id === a.id ? 'text-blue-600' : ''"
>v{{ a.version }}</span>
<NTag
v-if="a.source_message_id"
size="tiny"
class="ml-1"
>
AI
</NTag>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(a.created_at).toLocaleString('zh-CN', { hour12: false }) }}
</span>
</template>
<span
v-if="a.note"
class="text-xs text-gray-500"
>{{ a.note }}</span>
</NThing>
</NListItem>
</NList>
</div>
<template v-if="selected">
<NDivider />
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="prose prose-sm max-w-none"
v-html="rendered"
/>
</template>
</div>
<NEmpty
v-else-if="!loading"
description="暂无任何阶段产物"
/>
</NSpin>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NCard, NDivider, NEmpty, NList, NListItem, NSpin, NTag, NThing } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { Artifact, ArtifactPhase } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型',
auditing: '评审',
}
const PHASE_ORDER: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
const props = defineProps<{
slug: string
number: number
}>()
const store = useRequirementsStore()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const artifacts = ref<Artifact[]>([])
const loading = ref(false)
const selected = ref<Artifact | null>(null)
const grouped = computed(() =>
PHASE_ORDER.map((phase) => ({
phase,
items: artifacts.value.filter((a) => a.phase === phase),
})).filter((g) => g.items.length > 0),
)
const rendered = computed(() => (selected.value ? md.render(selected.value.content) : ''))
onMounted(async () => {
loading.value = true
try {
artifacts.value = await store.listArtifacts(props.slug, props.number)
} finally {
loading.value = false
}
})
</script>
@@ -0,0 +1,119 @@
<template>
<NModal
:show="show"
preset="card"
:title="`保存为${PHASE_TITLE[phase]}产物`"
class="max-w-4xl"
@update:show="(v: boolean) => emit('update:show', v)"
>
<div class="space-y-3">
<NTabs type="segment">
<NTabPane
name="edit"
tab="编辑"
>
<NInput
v-model:value="content"
type="textarea"
placeholder="产物内容(Markdown,可在保存前修改)"
:autosize="{ minRows: 12, maxRows: 24 }"
/>
</NTabPane>
<NTabPane
name="preview"
tab="预览"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="prose prose-sm max-w-none max-h-[60vh] overflow-y-auto border rounded p-3"
v-html="rendered"
/>
</NTabPane>
</NTabs>
<NInput
v-model:value="note"
placeholder="版本备注(可选)"
/>
<div class="flex justify-end gap-2">
<NButton @click="emit('update:show', false)">
取消
</NButton>
<NButton
type="primary"
:loading="saving"
:disabled="!content.trim()"
@click="onSave"
>
保存为新版本
</NButton>
</div>
</div>
</NModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NInput, NModal, NTabPane, NTabs, useMessage } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { ArtifactPhase } from '@/api/projects'
import type { ChatMessage } from '@/api/chat'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型',
auditing: '评审',
}
const props = defineProps<{
show: boolean
slug: string
number: number
phase: ArtifactPhase
sourceMessage: ChatMessage | null
}>()
const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>()
const store = useRequirementsStore()
const message = useMessage()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const content = ref('')
const note = ref('')
const saving = ref(false)
const rendered = computed(() => md.render(content.value))
// 每次打开时从来源消息初始化可编辑内容。
watch(
() => props.show,
(v) => {
if (v) {
content.value = props.sourceMessage?.content ?? ''
note.value = ''
}
},
)
async function onSave() {
if (!content.value.trim()) return
saving.value = true
try {
await store.createArtifact(props.slug, props.number, {
phase: props.phase,
content: content.value,
note: note.value || undefined,
source_message_id: props.sourceMessage?.id,
})
message.success('已保存为新版本')
emit('update:show', false)
emit('saved')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
</script>