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:
parent
5487cca199
commit
34a4164430
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue