feat(frontend): U5 前端协作关系图 + 验收/风险卡片

- types.ts 新增 ICollaborationContract/ICollaborationNotice/IReviewResult/IRiskFlag 等接口
- chat.ts 新增 collaborationState ref,处理 4 种协同事件
  (collaboration_contract_defined/collaboration_notice/review_result/risk_flagged)
  并在 plan_update 中提取 contracts,team_formed/dissolved 清理状态
- CollaborationGraphCard.vue SVG 协作关系图:
  圆形布局节点(专家首字),实线=契约,虚线动画=数据流向
  节点颜色编码验收状态(绿=通过,红=返工/失败),橙色!标记风险
- ReviewResultCard.vue 验收结果卡片(passed/failed + feedback)
- RiskFlagCard.vue 风险标记卡片(专家 + 风险描述)
- useMessageRenderer.ts 新增 3 个视图类型和渲染规格
- index.ts 导出 3 个新组件
- 遵循 U5 辩论可视化 BoardState 模式
- typecheck 通过
This commit is contained in:
chiguyong 2026-06-24 14:42:00 +08:00
parent 5487cca199
commit 34a4164430
7 changed files with 1019 additions and 0 deletions

View File

@ -62,6 +62,9 @@ export interface IChatMessage {
| 'debate_argument'
| 'debate_summary'
| 'debate_resolved'
| 'collaboration_graph'
| 'review_result'
| 'risk_flagged'
| 'error'
board_round?: number
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
@ -76,6 +79,12 @@ export interface IChatMessage {
debate_participants?: string[]
debate_opening?: string
debate_moderator?: string
/** U5: PM collaboration — aggregated graph data for CollaborationGraphCard */
collaboration_graph?: ICollaborationGraphData
/** U5: PM collaboration — review result for ReviewResultCard */
review_result?: IReviewResult
/** U5: PM collaboration — risk flag for RiskFlagCard */
risk_flag?: IRiskFlag
}
/** Conversation with messages */
@ -149,6 +158,11 @@ export type WsServerMessage =
| { type: 'debate_round_summary'; data: IDebateRoundSummaryData }
| { type: 'debate_resolved'; data: IDebateResolvedData }
| { type: 'team_intervention_ack'; data: { content: string } }
// PM Collaboration (U5) 事件
| { type: 'collaboration_contract_defined'; data: ICollaborationContractDefinedData }
| { type: 'collaboration_notice'; data: ICollaborationNotice }
| { type: 'review_result'; data: IReviewResult }
| { type: 'risk_flagged'; data: IRiskFlag }
// Calendar 事件 (KTD-10 — piggyback on chat WS)
| { type: 'calendar_event_created'; data: ICalendarEventCreatedData }
| { type: 'calendar_reminder'; data: ICalendarReminderData }
@ -178,6 +192,12 @@ export interface ITeamPlanPhase {
result?: string
parallel_type?: 'serial' | 'subtask_parallel' | 'competitive_parallel'
milestone?: string
/** U5: PM collaboration — contracts defined by Lead for this phase */
collaboration_contracts?: ICollaborationContract[]
/** U5: PM collaboration — rework count after Lead review failures */
rework_count?: number
/** U5: PM collaboration — Lead review feedback (modification requirements) */
review_feedback?: string | null
}
/** Expert team state */
@ -283,6 +303,64 @@ export interface IDebateResolvedData {
rationale: string
}
// ── PM Collaboration (U5) 模式类型 ──────────────────────────────────
/** 协作契约 — 匹配后端 CollaborationContract.to_dict() */
export interface ICollaborationContract {
from_expert: string
to_expert: string
content_description: string
status: 'pending' | 'delivered' | 'received'
}
/** collaboration_contract_defined event payload
* ( plan_update plan_phases[].collaboration_contracts
* ) */
export interface ICollaborationContractDefinedData {
phase_id: string
phase_name: string
contracts: ICollaborationContract[]
}
/** collaboration_notice event payload — 专家完成后按契约通知相关专家 */
export interface ICollaborationNotice {
from_expert: string
to_expert: string
content_description: string
phase_id: string
phase_name: string
output_key: string
expert_color: string
}
/** review_result event payload — Lead 验收阶段输出 */
export interface IReviewResult {
phase_id: string
phase_name: string
passed: boolean
feedback: string
expert: string
rework_count?: number
final_status?: 'rework' | 'failed'
}
/** risk_flagged event payload — 专家风险标记 */
export interface IRiskFlag {
expert: string
expert_name: string
risk_description: string
phase_id: string
phase_name: string
}
/** 协作关系图聚合数据 — 存储在 collaboration_graph 消息中,随事件实时更新 */
export interface ICollaborationGraphData {
contracts: Array<ICollaborationContract & { phase_id: string; phase_name: string }>
notices: ICollaborationNotice[]
reviews: IReviewResult[]
risks: IRiskFlag[]
}
/** Board meeting status (matches backend BoardStatus enum) */
export type BoardStatus = 'forming' | 'discussing' | 'concluding' | 'completed' | 'dissolved'

View File

@ -10,6 +10,9 @@ import DebateBannerCard from '@/components/chat/messages/DebateBannerCard.vue'
import DebateArgumentCard from '@/components/chat/messages/DebateArgumentCard.vue'
import DebateSummaryCard from '@/components/chat/messages/DebateSummaryCard.vue'
import DebateConclusionCard from '@/components/chat/messages/DebateConclusionCard.vue'
import CollaborationGraphCard from '@/components/chat/messages/CollaborationGraphCard.vue'
import ReviewResultCard from '@/components/chat/messages/ReviewResultCard.vue'
import RiskFlagCard from '@/components/chat/messages/RiskFlagCard.vue'
import ErrorCard from '@/components/chat/messages/ErrorCard.vue'
export type MessageViewType =
@ -24,6 +27,9 @@ export type MessageViewType =
| 'debate_argument'
| 'debate_summary'
| 'debate_resolved'
| 'collaboration_graph'
| 'review_result'
| 'risk_flagged'
| 'milestone'
| 'error'
@ -64,6 +70,12 @@ export function resolveMessageType(message: IChatMessage): MessageViewType {
return 'debate_summary'
case 'debate_resolved':
return 'debate_resolved'
case 'collaboration_graph':
return 'collaboration_graph'
case 'review_result':
return 'review_result'
case 'risk_flagged':
return 'risk_flagged'
case 'milestone':
return 'milestone'
default:
@ -261,6 +273,68 @@ export function useMessageRenderer(message: IChatMessage) {
}
}
case 'collaboration_graph': {
const graphData = message.collaboration_graph ?? {
contracts: [],
notices: [],
reviews: [],
risks: [],
}
return {
type,
shell: {
name: '协作关系图',
avatar: '◆',
color: '#1890ff',
meta: time,
},
component: CollaborationGraphCard,
props: { graphData },
}
}
case 'review_result': {
const review = message.review_result ?? {
phase_id: '',
phase_name: '',
passed: false,
feedback: message.content,
expert: message.expert_name || '',
}
return {
type,
shell: {
name: '验收结果',
avatar: review.passed ? '\u2713' : '\u2717',
color: review.passed ? '#52c41a' : '#ff4d4f',
meta: review.phase_name || time,
},
component: ReviewResultCard,
props: { review },
}
}
case 'risk_flagged': {
const risk = message.risk_flag ?? {
expert: message.expert_name || '',
expert_name: message.expert_name || '',
risk_description: message.content,
phase_id: '',
phase_name: '',
}
return {
type,
shell: {
name: '风险标记',
avatar: '!',
color: '#fa8c16',
meta: risk.phase_name || time,
},
component: RiskFlagCard,
props: { risk },
}
}
case 'error':
return {
type,

View File

@ -0,0 +1,440 @@
<template>
<div class="collab-graph">
<div class="collab-graph__header">
<span class="collab-graph__title">协作关系图</span>
<span v-if="contractEdges.length > 0" class="collab-graph__count">
{{ contractEdges.length }} 项契约 · {{ noticeEdges.length }} 项数据流
</span>
</div>
<div class="collab-graph__legend">
<span class="legend-item">
<span class="legend-line legend-line--solid"></span>协作契约
</span>
<span class="legend-item">
<span class="legend-line legend-line--dashed"></span>数据流向
</span>
<span class="legend-item">
<span class="legend-dot legend-dot--passed"></span>验收通过
</span>
<span class="legend-item">
<span class="legend-dot legend-dot--rework"></span>返工/失败
</span>
</div>
<svg
v-if="nodes.length > 0"
:viewBox="`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`"
class="collab-graph__svg"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<marker
id="collab-arrow-contract"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8c8c8c" />
</marker>
<marker
id="collab-arrow-notice"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1890ff" />
</marker>
</defs>
<!-- Contract edges (solid lines) -->
<g class="collab-graph__edges">
<line
v-for="(edge, i) in contractEdges"
:key="`contract-${i}`"
:x1="edge.x1"
:y1="edge.y1"
:x2="edge.x2"
:y2="edge.y2"
class="collab-edge collab-edge--contract"
:class="{ 'collab-edge--delivered': edge.delivered }"
marker-end="url(#collab-arrow-contract)"
>
<title>{{ edge.from }} {{ edge.to }}: {{ edge.content }}</title>
</line>
</g>
<!-- Notice edges (dashed animated lines) -->
<g class="collab-graph__edges">
<line
v-for="(edge, i) in noticeEdges"
:key="`notice-${i}`"
:x1="edge.x1"
:y1="edge.y1"
:x2="edge.x2"
:y2="edge.y2"
class="collab-edge collab-edge--notice"
marker-end="url(#collab-arrow-notice)"
>
<title>{{ edge.from }} {{ edge.to }}: {{ edge.content }}</title>
</line>
</g>
<!-- Nodes -->
<g class="collab-graph__nodes">
<g
v-for="node in nodes"
:key="node.name"
:transform="`translate(${node.x}, ${node.y})`"
>
<circle
:r="NODE_RADIUS"
class="collab-node"
:class="`collab-node--${node.status}`"
/>
<text
class="collab-node__initial"
text-anchor="middle"
dy="0.35em"
>{{ node.initial }}</text>
<text
class="collab-node__name"
text-anchor="middle"
:y="NODE_RADIUS + 14"
>{{ node.name }}</text>
<text
v-if="node.hasRisk"
class="collab-node__risk"
text-anchor="middle"
:y="-(NODE_RADIUS + 4)"
>!</text>
</g>
</g>
</svg>
<div v-else class="collab-graph__empty">
<span class="collab-graph__empty-text">暂无协作关系</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ICollaborationGraphData, IReviewResult } from '@/api/types'
const props = defineProps<{
graphData: ICollaborationGraphData
}>()
// SVG dimensions and layout constants
const SVG_WIDTH = 340
const SVG_HEIGHT = 280
const NODE_RADIUS = 22
type NodeStatus = 'default' | 'passed' | 'rework' | 'failed'
interface IGraphNode {
name: string
initial: string
x: number
y: number
status: NodeStatus
hasRisk: boolean
}
interface IGraphEdge {
x1: number
y1: number
x2: number
y2: number
from: string
to: string
content: string
delivered?: boolean
}
/** Collect unique expert names from all data sources (contracts, notices, reviews, risks). */
const experts = computed<string[]>(() => {
const names = new Set<string>()
for (const c of props.graphData.contracts) {
if (c.from_expert) names.add(c.from_expert)
if (c.to_expert) names.add(c.to_expert)
}
for (const n of props.graphData.notices) {
if (n.from_expert) names.add(n.from_expert)
if (n.to_expert) names.add(n.to_expert)
}
for (const r of props.graphData.reviews) {
if (r.expert) names.add(r.expert)
}
for (const r of props.graphData.risks) {
if (r.expert) names.add(r.expert)
if (r.expert_name) names.add(r.expert_name)
}
return Array.from(names)
})
/** Determine the review status of an expert based on review_result events. */
function getExpertStatus(name: string, reviews: IReviewResult[]): NodeStatus {
const expertReviews = reviews.filter((r) => r.expert === name)
if (expertReviews.length === 0) return 'default'
// If any review failed with final_status='failed' failed
if (expertReviews.some((r) => !r.passed && r.final_status === 'failed')) return 'failed'
// If any review failed (rework in progress) rework
if (expertReviews.some((r) => !r.passed)) return 'rework'
// If all reviews passed passed
if (expertReviews.every((r) => r.passed)) return 'passed'
return 'default'
}
/** Compute node positions using circular layout. */
const nodes = computed<IGraphNode[]>(() => {
const names = experts.value
const n = names.length
if (n === 0) return []
const cx = SVG_WIDTH / 2
const cy = SVG_HEIGHT / 2
const radius = Math.min(SVG_WIDTH, SVG_HEIGHT) / 2 - 55
return names.map((name, i) => {
const angle = (i / n) * 2 * Math.PI - Math.PI / 2 // start from top
return {
name,
initial: name.charAt(0) || '?',
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
status: getExpertStatus(name, props.graphData.reviews),
hasRisk: props.graphData.risks.some(
(r) => r.expert === name || r.expert_name === name,
),
}
})
})
/** Clip an edge to start/end at circle boundaries so arrowheads are visible. */
function clipEdge(
fromX: number,
fromY: number,
toX: number,
toY: number,
): { x1: number; y1: number; x2: number; y2: number } {
const dx = toX - fromX
const dy = toY - fromY
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist === 0) return { x1: fromX, y1: fromY, x2: toX, y2: toY }
const ux = dx / dist
const uy = dy / dist
return {
x1: fromX + ux * NODE_RADIUS,
y1: fromY + uy * NODE_RADIUS,
x2: toX - ux * (NODE_RADIUS + 4), // extra offset for arrowhead
y2: toY - uy * (NODE_RADIUS + 4),
}
}
/** Build edge list from collaboration contracts (solid lines). */
const contractEdges = computed<IGraphEdge[]>(() => {
return props.graphData.contracts
.filter((c) => c.from_expert && c.to_expert)
.flatMap((c): IGraphEdge[] => {
const from = nodes.value.find((n) => n.name === c.from_expert)
const to = nodes.value.find((n) => n.name === c.to_expert)
if (!from || !to) return []
const clipped = clipEdge(from.x, from.y, to.x, to.y)
return [{
...clipped,
from: c.from_expert,
to: c.to_expert,
content: c.content_description,
delivered: c.status === 'delivered',
}]
})
})
/** Build edge list from collaboration notices (dashed animated lines). */
const noticeEdges = computed<IGraphEdge[]>(() => {
return props.graphData.notices
.filter((n) => n.from_expert && n.to_expert)
.flatMap((n): IGraphEdge[] => {
const from = nodes.value.find((node) => node.name === n.from_expert)
const to = nodes.value.find((node) => node.name === n.to_expert)
if (!from || !to) return []
const clipped = clipEdge(from.x, from.y, to.x, to.y)
return [{
...clipped,
from: n.from_expert,
to: n.to_expert,
content: n.content_description,
}]
})
})
</script>
<style scoped>
.collab-graph {
padding: 12px 16px;
border-left: 3px solid #1890ff;
background: #f0f8ff;
border-radius: 4px;
}
.collab-graph__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.collab-graph__title {
font-weight: 600;
color: #1890ff;
font-size: 14px;
}
.collab-graph__count {
font-size: 12px;
color: #666;
}
.collab-graph__legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
.legend-line {
display: inline-block;
width: 20px;
height: 0;
border-top: 2px solid #8c8c8c;
}
.legend-line--solid {
border-top-style: solid;
}
.legend-line--dashed {
border-top: 2px dashed #1890ff;
}
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-dot--passed {
background: #52c41a;
}
.legend-dot--rework {
background: #ff4d4f;
}
.collab-graph__svg {
width: 100%;
max-height: 300px;
}
/* Edges */
.collab-edge--contract {
stroke: #8c8c8c;
stroke-width: 1.5;
fill: none;
opacity: 0.7;
}
.collab-edge--contract.collab-edge--delivered {
stroke: #52c41a;
opacity: 0.9;
}
.collab-edge--notice {
stroke: #1890ff;
stroke-width: 2;
stroke-dasharray: 6 4;
fill: none;
animation: collab-dash-flow 1s linear infinite;
}
@keyframes collab-dash-flow {
to {
stroke-dashoffset: -10;
}
}
/* Nodes */
.collab-node {
stroke-width: 2;
transition: fill 0.3s, stroke 0.3s;
}
.collab-node--default {
fill: #f5f5f5;
stroke: #d9d9d9;
}
.collab-node--passed {
fill: #f6ffed;
stroke: #52c41a;
}
.collab-node--rework {
fill: #fff2f0;
stroke: #ff4d4f;
}
.collab-node--failed {
fill: #fff2f0;
stroke: #ff4d4f;
stroke-dasharray: 4 2;
}
.collab-node__initial {
font-size: 14px;
font-weight: 600;
fill: #333;
pointer-events: none;
user-select: none;
}
.collab-node__name {
font-size: 11px;
fill: #666;
pointer-events: none;
user-select: none;
}
.collab-node__risk {
font-size: 16px;
font-weight: bold;
fill: #fa8c16;
}
.collab-graph__empty {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
}
.collab-graph__empty-text {
font-size: 13px;
color: #999;
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<div class="review-card" :class="reviewClass">
<div class="review-card__header">
<span class="review-card__icon">{{ statusIcon }}</span>
<span class="review-card__status">{{ statusLabel }}</span>
<a-tag v-if="review.rework_count !== undefined && review.rework_count > 0" :color="review.passed ? 'green' : 'red'">
返工 {{ review.rework_count }}
</a-tag>
</div>
<div class="review-card__meta">
<span class="review-card__phase">{{ review.phase_name }}</span>
<span class="review-card__expert">{{ review.expert }}</span>
</div>
<div v-if="review.feedback" class="review-card__feedback">
<AssistantText :message="feedbackMessage" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage, IReviewResult } from '@/api/types'
const props = defineProps<{
review: IReviewResult
}>()
const reviewClass = computed(() => {
if (props.review.passed) return 'review-card--passed'
if (props.review.final_status === 'failed') return 'review-card--failed'
return 'review-card--rework'
})
const statusLabel = computed(() => {
if (props.review.passed) return '验收通过'
if (props.review.final_status === 'failed') return '验收失败'
return '要求返工'
})
const statusIcon = computed(() => {
if (props.review.passed) return '\u2713'
if (props.review.final_status === 'failed') return '\u2717'
return '\u21bb'
})
const feedbackMessage = computed<IChatMessage>(() => ({
id: `review-feedback-${props.review.phase_id}`,
role: 'assistant',
content: props.review.feedback,
timestamp: new Date().toISOString(),
status: 'completed',
}))
</script>
<style scoped>
.review-card {
padding: 10px 14px;
border-left: 3px solid #d9d9d9;
background: #fafafa;
border-radius: 4px;
}
.review-card--passed {
border-left-color: #52c41a;
background: #f6ffed;
}
.review-card--rework {
border-left-color: #faad14;
background: #fffbe6;
}
.review-card--failed {
border-left-color: #ff4d4f;
background: #fff2f0;
}
.review-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.review-card__icon {
font-size: 16px;
font-weight: bold;
}
.review-card--passed .review-card__icon {
color: #52c41a;
}
.review-card--rework .review-card__icon {
color: #faad14;
}
.review-card--failed .review-card__icon {
color: #ff4d4f;
}
.review-card__status {
font-weight: 600;
font-size: 14px;
}
.review-card__meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #666;
margin-bottom: 6px;
}
.review-card__phase::before {
content: '阶段: ';
color: #999;
}
.review-card__expert::before {
content: '专家: ';
color: #999;
}
.review-card__feedback {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed #d9d9d9;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<div class="risk-card">
<div class="risk-card__header">
<span class="risk-card__icon">!</span>
<span class="risk-card__title">风险标记</span>
<span class="risk-card__expert">{{ risk.expert_name || risk.expert }}</span>
</div>
<div class="risk-card__meta">
<span class="risk-card__phase">{{ risk.phase_name }}</span>
</div>
<div class="risk-card__description">
<AssistantText :message="descMessage" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage, IRiskFlag } from '@/api/types'
const props = defineProps<{
risk: IRiskFlag
}>()
const descMessage = computed<IChatMessage>(() => ({
id: `risk-desc-${props.risk.phase_id}-${props.risk.expert}`,
role: 'assistant',
content: props.risk.risk_description,
timestamp: new Date().toISOString(),
status: 'completed',
}))
</script>
<style scoped>
.risk-card {
padding: 10px 14px;
border-left: 3px solid #fa8c16;
background: #fff7e6;
border-radius: 4px;
}
.risk-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.risk-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fa8c16;
color: #fff;
font-size: 12px;
font-weight: bold;
}
.risk-card__title {
font-weight: 600;
font-size: 14px;
color: #fa8c16;
}
.risk-card__expert {
font-size: 12px;
color: #666;
margin-left: auto;
}
.risk-card__meta {
font-size: 12px;
color: #666;
margin-bottom: 6px;
}
.risk-card__phase::before {
content: '阶段: ';
color: #999;
}
.risk-card__description {
font-size: 13px;
color: #333;
}
</style>

View File

@ -9,5 +9,8 @@ export { default as DebateBannerCard } from './DebateBannerCard.vue'
export { default as DebateArgumentCard } from './DebateArgumentCard.vue'
export { default as DebateSummaryCard } from './DebateSummaryCard.vue'
export { default as DebateConclusionCard } from './DebateConclusionCard.vue'
export { default as CollaborationGraphCard } from './CollaborationGraphCard.vue'
export { default as ReviewResultCard } from './ReviewResultCard.vue'
export { default as RiskFlagCard } from './RiskFlagCard.vue'
export { default as ErrorCard } from './ErrorCard.vue'
export { default as FileAttachment } from './FileAttachment.vue'

View File

@ -14,6 +14,12 @@ import type {
IDebateArgumentData,
IDebateRoundSummaryData,
IDebateResolvedData,
ICollaborationContract,
ICollaborationContractDefinedData,
ICollaborationNotice,
IReviewResult,
IRiskFlag,
ICollaborationGraphData,
} from '@/api/types'
function generateId(): string {
@ -161,6 +167,10 @@ export const useChatStore = defineStore('chat', () => {
status: 'debating' | 'resolved' | 'cancelled'
} | null>(null)
// PM Collaboration state (transient, only active during a PM-mode team task)
// Tracks contracts, notices, reviews, and risks for the collaboration graph.
const collaborationState = ref<ICollaborationGraphData | null>(null)
// --- Getters ---
const currentConversation = computed<IConversation | undefined>(() => {
return conversations.value.find((c) => c.id === currentConversationId.value)
@ -596,6 +606,48 @@ export const useChatStore = defineStore('chat', () => {
return _teamStore
}
/** Ensure a collaboration_graph message exists in the conversation and update
* it with the latest graph data. Creates the message if absent. */
function upsertCollaborationGraph(conversationId: string, graphData: ICollaborationGraphData): void {
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) return
const existing = [...conv.messages].reverse().find((m) => m.message_type === 'collaboration_graph')
if (existing) {
updateMessage(conversationId, existing.id, {
collaboration_graph: {
contracts: [...graphData.contracts],
notices: [...graphData.notices],
reviews: [...graphData.reviews],
risks: [...graphData.risks],
},
})
} else {
const graphMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'collaboration_graph',
collaboration_graph: {
contracts: [...graphData.contracts],
notices: [...graphData.notices],
reviews: [...graphData.reviews],
risks: [...graphData.risks],
},
}
appendMessage(conversationId, graphMsg)
}
}
/** Ensure collaborationState is initialized; return the live data object. */
function _ensureCollaborationState(): ICollaborationGraphData {
if (!collaborationState.value) {
collaborationState.value = { contracts: [], notices: [], reviews: [], risks: [] }
}
return collaborationState.value
}
function handleWsMessage(data: WsServerMessage): void {
// Discriminated union narrowing: each `case` branch narrows `data` to a
// specific variant of WsServerMessage, so typed fields can be accessed
@ -881,6 +933,8 @@ export const useChatStore = defineStore('chat', () => {
}
case 'team_formed': {
// Reset collaboration state for a fresh team
collaborationState.value = null
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const teamStore = _getTeamStore()
@ -980,6 +1034,36 @@ export const useChatStore = defineStore('chat', () => {
}
appendMessage(conversationId, planMsg)
}
// U5: Extract collaboration contracts from plan_phases and populate
// collaborationState + collaboration_graph message. The backend
// includes contracts inside plan_update (not as a separate event).
const extractedContracts: Array<ICollaborationContract & { phase_id: string; phase_name: string }> = []
for (const phase of data.data.plan_phases) {
if (phase.collaboration_contracts && phase.collaboration_contracts.length > 0) {
for (const c of phase.collaboration_contracts) {
extractedContracts.push({
from_expert: c.from_expert,
to_expert: c.to_expert,
content_description: c.content_description,
status: c.status,
phase_id: phase.id,
phase_name: phase.name,
})
}
}
}
if (extractedContracts.length > 0) {
const collab = _ensureCollaborationState()
collab.contracts = extractedContracts
upsertCollaborationGraph(conversationId, collab)
appendStep({
type: 'team_event',
label: '协作契约定义',
detail: `${extractedContracts.length} 项契约`,
status: 'success',
}, conversationId)
}
break
}
@ -1005,6 +1089,8 @@ export const useChatStore = defineStore('chat', () => {
if (teamStore) {
teamStore.clearTeam()
}
// Clear collaboration state — team is done
collaborationState.value = null
const cid = resolveIncomingConvId()
if (cid) {
appendStep({
@ -1336,6 +1422,121 @@ export const useChatStore = defineStore('chat', () => {
}, sessionId)
break
}
// ── PM Collaboration (U5) 事件 ──────────────────────────────────
case 'collaboration_contract_defined': {
const d = data.data as ICollaborationContractDefinedData
const collab = _ensureCollaborationState()
// Replace contracts for this phase, keep contracts from other phases
const others = collab.contracts.filter((c) => c.phase_id !== d.phase_id)
const newContracts: Array<ICollaborationContract & { phase_id: string; phase_name: string }> =
d.contracts.map((c) => ({
from_expert: c.from_expert,
to_expert: c.to_expert,
content_description: c.content_description,
status: c.status,
phase_id: d.phase_id,
phase_name: d.phase_name,
}))
collab.contracts = [...others, ...newContracts]
const sessionId = resolveIncomingConvId()
if (sessionId) {
upsertCollaborationGraph(sessionId, collab)
appendStep({
type: 'team_event',
label: '协作契约定义',
detail: `${newContracts.length} 项契约 · ${d.phase_name}`,
status: 'success',
}, sessionId)
}
break
}
case 'collaboration_notice': {
const d = data.data as ICollaborationNotice
const collab = _ensureCollaborationState()
// Dedup by output_key to avoid duplicate notices on replay
if (!collab.notices.some((n) => n.output_key === d.output_key && n.from_expert === d.from_expert)) {
collab.notices.push(d)
}
const sessionId = resolveIncomingConvId()
if (sessionId) {
upsertCollaborationGraph(sessionId, collab)
appendStep({
type: 'team_event',
label: '协作通知',
detail: `${d.from_expert}${d.to_expert}`,
status: 'success',
}, sessionId)
}
break
}
case 'review_result': {
const d = data.data as IReviewResult
const collab = _ensureCollaborationState()
// Replace any existing review for the same phase to keep latest state
collab.reviews = collab.reviews.filter((r) => r.phase_id !== d.phase_id)
collab.reviews.push(d)
const sessionId = resolveIncomingConvId()
if (sessionId) {
// Update the collaboration graph with new review status (node colors)
upsertCollaborationGraph(sessionId, collab)
// Create a dedicated ReviewResultCard message
appendMessage(sessionId, {
id: generateId(),
role: 'assistant',
content: d.feedback || (d.passed ? '验收通过' : '验收未通过'),
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'review_result',
review_result: d,
expert_name: d.expert,
})
appendStep({
type: 'team_event',
label: d.passed ? '验收通过' : (d.final_status === 'failed' ? '验收失败' : '要求返工'),
detail: d.phase_name,
status: d.passed ? 'success' : 'error',
}, sessionId)
}
break
}
case 'risk_flagged': {
const d = data.data as IRiskFlag
const collab = _ensureCollaborationState()
// Dedup by expert + phase + description to avoid duplicates on replay
if (!collab.risks.some(
(r) => r.expert === d.expert && r.phase_id === d.phase_id && r.risk_description === d.risk_description,
)) {
collab.risks.push(d)
}
const sessionId = resolveIncomingConvId()
if (sessionId) {
// Update the collaboration graph to show risk marker on the node
upsertCollaborationGraph(sessionId, collab)
// Create a dedicated RiskFlagCard message
appendMessage(sessionId, {
id: generateId(),
role: 'assistant',
content: d.risk_description,
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'risk_flagged',
risk_flag: d,
expert_name: d.expert_name || d.expert,
})
appendStep({
type: 'team_event',
label: '风险标记',
detail: `${d.expert_name || d.expert}: ${d.risk_description.slice(0, 30)}`,
status: 'error',
}, sessionId)
}
break
}
}
}
@ -1389,6 +1590,7 @@ export const useChatStore = defineStore('chat', () => {
streamingStepsByConv,
boardState,
debateState,
collaborationState,
// Legacy aliases (derive from current conversation for backward compat).
// New code should use `isCurrentLoading` / `currentStreamingSteps` instead.
isLoading: isCurrentLoading,