You've already forked agentic-coding-workflow
270 lines
8.8 KiB
Vue
270 lines
8.8 KiB
Vue
<template>
|
|
<NCard title="产物与会话历史">
|
|
<NSpin :show="loading">
|
|
<NCollapse v-if="hasAnyContent" :default-expanded-names="defaultExpandedNames">
|
|
<!-- 产物:按阶段分组 -->
|
|
<NCollapseItem
|
|
v-for="phase in ARTIFACT_PHASES"
|
|
:key="phase"
|
|
:name="phase"
|
|
:disabled="artifactsByPhase[phase].length === 0"
|
|
>
|
|
<template #header>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium">{{ PHASE_TITLE[phase] }}产物</span>
|
|
<NTag size="tiny" :type="phase === currentPhase ? 'primary' : 'default'">
|
|
{{ artifactsByPhase[phase].length }}
|
|
</NTag>
|
|
<span v-if="phase === currentPhase" class="text-xs text-blue-500">当前</span>
|
|
</div>
|
|
</template>
|
|
|
|
<NList v-if="artifactsByPhase[phase].length > 0" :show-divider="false">
|
|
<NListItem v-for="a in artifactsByPhase[phase]" :key="a.id">
|
|
<NThing class="cursor-pointer" @click="toggleArtifact(a)">
|
|
<template #header>
|
|
<span
|
|
class="text-sm font-medium"
|
|
:class="selectedArtifact?.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">
|
|
{{ formatDate(a.created_at) }}
|
|
</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 v-if="selectedArtifact && selectedArtifact.phase === phase" class="mt-3">
|
|
<NDivider />
|
|
<!-- eslint-disable vue/no-v-html -->
|
|
<div class="prose prose-sm max-w-none" v-html="renderedArtifact" />
|
|
<!-- eslint-enable vue/no-v-html -->
|
|
</div>
|
|
</NCollapseItem>
|
|
|
|
<!-- AI 对话 -->
|
|
<NCollapseItem name="conversations" :disabled="conversations.length === 0">
|
|
<template #header>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium">AI 对话</span>
|
|
<NTag size="tiny">
|
|
{{ conversations.length }}
|
|
</NTag>
|
|
</div>
|
|
</template>
|
|
|
|
<NList v-if="conversations.length > 0" :show-divider="false">
|
|
<NListItem v-for="c in conversations" :key="c.id">
|
|
<NThing class="cursor-pointer" @click="goConversation(c)">
|
|
<template #header>
|
|
<span
|
|
class="text-sm font-medium"
|
|
:class="selectedConversation?.id === c.id ? 'text-blue-600' : ''"
|
|
>
|
|
{{ c.title || '(未命名)' }}
|
|
</span>
|
|
</template>
|
|
<template #header-extra>
|
|
<span class="text-xs text-gray-400">
|
|
{{ formatDateTime(c.updated_at) }}
|
|
</span>
|
|
</template>
|
|
</NThing>
|
|
</NListItem>
|
|
</NList>
|
|
<NEmpty v-else description="暂无对话" size="small" />
|
|
</NCollapseItem>
|
|
|
|
<!-- ACP 会话 -->
|
|
<NCollapseItem name="acp-sessions" :disabled="acpSessions.length === 0">
|
|
<template #header>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium">ACP 会话</span>
|
|
<NTag size="tiny">
|
|
{{ acpSessions.length }}
|
|
</NTag>
|
|
</div>
|
|
</template>
|
|
|
|
<NList v-if="acpSessions.length > 0" :show-divider="false">
|
|
<NListItem v-for="s in acpSessions" :key="s.id">
|
|
<NThing
|
|
:class="workspaceSlug ? 'cursor-pointer' : ''"
|
|
@click="workspaceSlug ? goAcpSession(s) : undefined"
|
|
>
|
|
<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">
|
|
{{ formatDateTime(s.started_at) }}
|
|
<template v-if="s.last_error"> · {{ s.last_error }}</template>
|
|
</span>
|
|
</NThing>
|
|
</NListItem>
|
|
</NList>
|
|
<NEmpty v-else description="暂无会话" size="small" />
|
|
</NCollapseItem>
|
|
</NCollapse>
|
|
|
|
<NEmpty v-else-if="!loading" description="暂无历史产物与会话" size="small" />
|
|
</NSpin>
|
|
</NCard>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import {
|
|
NCard,
|
|
NCollapse,
|
|
NCollapseItem,
|
|
NDivider,
|
|
NEmpty,
|
|
NList,
|
|
NListItem,
|
|
NSpin,
|
|
NTag,
|
|
NThing,
|
|
} from 'naive-ui'
|
|
|
|
import { chatApi, type Conversation } from '@/api/chat'
|
|
import { acpApi, type AcpSession } from '@/api/acp'
|
|
import type { Artifact, ArtifactPhase, Phase } from '@/api/projects'
|
|
import { md } from '@/utils/markdown'
|
|
import { formatDate, formatDateTime } from '@/utils/date'
|
|
import { useRequirementsStore } from '@/stores/requirements'
|
|
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
|
|
|
const ARTIFACT_PHASES: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
|
|
|
|
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
|
planning: '规划',
|
|
prototyping: '原型',
|
|
auditing: '评审',
|
|
}
|
|
|
|
const props = defineProps<{
|
|
slug: string
|
|
number: number
|
|
requirementId: string
|
|
workspaceSlug: string | null
|
|
currentPhase?: Phase | null
|
|
}>()
|
|
|
|
const router = useRouter()
|
|
const store = useRequirementsStore()
|
|
|
|
const loading = ref(false)
|
|
const artifacts = ref<Artifact[]>([])
|
|
const conversations = ref<Conversation[]>([])
|
|
const acpSessions = ref<AcpSession[]>([])
|
|
|
|
const selectedArtifact = ref<Artifact | null>(null)
|
|
const selectedConversation = ref<Conversation | null>(null)
|
|
|
|
const artifactsByPhase = computed(() => {
|
|
const map: Record<ArtifactPhase, Artifact[]> = {
|
|
planning: [],
|
|
prototyping: [],
|
|
auditing: [],
|
|
}
|
|
for (const a of artifacts.value) {
|
|
if (map[a.phase]) {
|
|
map[a.phase].push(a)
|
|
}
|
|
}
|
|
// 每个阶段内按版本号排序
|
|
for (const phase of ARTIFACT_PHASES) {
|
|
map[phase].sort((a, b) => a.version - b.version)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const hasAnyContent = computed(
|
|
() =>
|
|
artifacts.value.length > 0 || conversations.value.length > 0 || acpSessions.value.length > 0,
|
|
)
|
|
|
|
// 默认展开有内容的第一项(优先当前阶段)
|
|
const defaultExpandedNames = ref<(ArtifactPhase | 'conversations' | 'acp-sessions')[]>([])
|
|
watch(
|
|
[
|
|
() => props.currentPhase,
|
|
() => artifacts.value.length,
|
|
() => conversations.value.length,
|
|
() => acpSessions.value.length,
|
|
],
|
|
() => {
|
|
const names: (ArtifactPhase | 'conversations' | 'acp-sessions')[] = []
|
|
if (props.currentPhase && (ARTIFACT_PHASES as Phase[]).includes(props.currentPhase)) {
|
|
const cp = props.currentPhase as ArtifactPhase
|
|
if (artifactsByPhase.value[cp].length > 0) names.push(cp)
|
|
}
|
|
for (const phase of ARTIFACT_PHASES) {
|
|
if (artifactsByPhase.value[phase].length > 0 && !names.includes(phase)) {
|
|
names.push(phase)
|
|
}
|
|
}
|
|
if (conversations.value.length > 0) names.push('conversations')
|
|
if (acpSessions.value.length > 0) names.push('acp-sessions')
|
|
defaultExpandedNames.value = names.slice(0, 2) // 最多默认展开前两项,避免过长
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
const renderedArtifact = computed(() =>
|
|
selectedArtifact.value ? md.render(selectedArtifact.value.content) : '',
|
|
)
|
|
|
|
function toggleArtifact(a: Artifact) {
|
|
selectedArtifact.value = selectedArtifact.value?.id === a.id ? null : a
|
|
}
|
|
|
|
function goConversation(c: Conversation) {
|
|
selectedConversation.value = c
|
|
router.push({
|
|
name: 'chat-detail',
|
|
params: { conversationId: c.id },
|
|
})
|
|
}
|
|
|
|
function goAcpSession(s: AcpSession) {
|
|
if (!props.workspaceSlug) return
|
|
router.push({
|
|
name: 'acp-sessions-detail',
|
|
params: { slug: props.slug, wsSlug: props.workspaceSlug, sessionId: s.id },
|
|
})
|
|
}
|
|
|
|
async function refresh() {
|
|
loading.value = true
|
|
try {
|
|
const [arts, convs, sessions] = await Promise.all([
|
|
store.listArtifacts(props.slug, props.number),
|
|
chatApi.listConversations({ requirement_id: props.requirementId }),
|
|
acpApi.sessions.list({ requirement_id: props.requirementId }),
|
|
])
|
|
artifacts.value = arts
|
|
conversations.value = convs
|
|
acpSessions.value = sessions
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(refresh)
|
|
|
|
defineExpose({ refresh })
|
|
</script>
|