You've already forked agentic-coding-workflow
主流程
This commit is contained in:
+7
-2
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user