fix(chat): U2 消息模型与分发器对齐后端事件
- board_started 现在保存为结构化消息并渲染 BoardBannerCard - board_concluded 现在追加 board_conclusion 结构化消息 - 扩展 IChatMessage.status 包含 error - 移除 chat.ts 中的 any 类型(保留 handleWsMessage 遗留 TODO) - BoardBannerCard v-for key 使用 name-index 组合避免重复
This commit is contained in:
parent
a2c6af54b8
commit
ff22946655
|
|
@ -38,16 +38,30 @@ export interface IChatMessage {
|
||||||
routing_method?: string
|
routing_method?: string
|
||||||
confidence?: number
|
confidence?: number
|
||||||
task_id?: string
|
task_id?: string
|
||||||
status?: 'completed' | 'pending'
|
status?: 'completed' | 'pending' | 'error'
|
||||||
tool_calls?: IToolCallData[]
|
tool_calls?: IToolCallData[]
|
||||||
thinking?: string
|
thinking?: string
|
||||||
expert_id?: string
|
expert_id?: string
|
||||||
expert_name?: string
|
expert_name?: string
|
||||||
expert_color?: string
|
expert_color?: string
|
||||||
expert_avatar?: string
|
expert_avatar?: string
|
||||||
message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone' | 'board_speech' | 'board_summary' | 'board_conclusion'
|
message_type?:
|
||||||
|
| 'chat'
|
||||||
|
| 'handoff'
|
||||||
|
| 'assist_request'
|
||||||
|
| 'plan_update'
|
||||||
|
| 'milestone'
|
||||||
|
| 'board_started'
|
||||||
|
| 'board_speech'
|
||||||
|
| 'board_summary'
|
||||||
|
| 'board_conclusion'
|
||||||
|
| 'error'
|
||||||
board_round?: number
|
board_round?: number
|
||||||
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
|
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
|
||||||
|
plan_phases?: ITeamPlanPhase[]
|
||||||
|
error_detail?: string
|
||||||
|
board_started?: IBoardStartedData
|
||||||
|
board_conclusion?: IBoardConcludedData
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Conversation with messages */
|
/** Conversation with messages */
|
||||||
|
|
@ -218,6 +232,34 @@ export interface IBoardMessage {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Expert template (matches backend GET /api/v1/experts response item) */
|
||||||
|
export interface IExpertTemplate {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
is_builtin: boolean
|
||||||
|
avatar: string
|
||||||
|
color: string
|
||||||
|
persona: string
|
||||||
|
thinking_style: string
|
||||||
|
speaking_style: string
|
||||||
|
decision_framework: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Experts list response (matches backend GET /api/v1/experts) */
|
||||||
|
export interface IExpertsResponse {
|
||||||
|
experts: IExpertTemplate[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File upload response (matches backend POST /api/v1/chat/upload) */
|
||||||
|
export interface IUploadResponse {
|
||||||
|
filename: string
|
||||||
|
stored_name: string
|
||||||
|
content_type: string
|
||||||
|
size: number
|
||||||
|
download_url: string
|
||||||
|
}
|
||||||
|
|
||||||
/** API error */
|
/** API error */
|
||||||
export interface IApiError {
|
export interface IApiError {
|
||||||
status: number
|
status: number
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<template>
|
||||||
|
<div class="board-banner-card">
|
||||||
|
<div class="board-banner-card__bar" />
|
||||||
|
<div class="board-banner-card__body">
|
||||||
|
<div class="board-banner-card__title">
|
||||||
|
<span class="board-banner-card__icon">🏛️</span>
|
||||||
|
<span>私董会 — {{ topic }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="board-banner-card__experts">
|
||||||
|
<span
|
||||||
|
v-for="(expert, idx) in experts"
|
||||||
|
:key="`${expert.name}-${idx}`"
|
||||||
|
class="board-banner-card__chip"
|
||||||
|
:class="{ 'board-banner-card__chip--moderator': expert.is_moderator }"
|
||||||
|
>
|
||||||
|
<span class="board-banner-card__chip-avatar">{{ expert.avatar }}</span>
|
||||||
|
<span>{{ expert.name }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="board-banner-card__meta">
|
||||||
|
<span>轮次:第 {{ currentRound }} / {{ maxRounds }} 轮</span>
|
||||||
|
<div class="board-banner-card__progress">
|
||||||
|
<div
|
||||||
|
class="board-banner-card__progress-fill"
|
||||||
|
:style="{ width: progressPercent + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { IBoardExpert } from '@/api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
topic: string
|
||||||
|
experts: IBoardExpert[]
|
||||||
|
maxRounds: number
|
||||||
|
currentRound?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
currentRound: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
if (props.maxRounds <= 0) return 0
|
||||||
|
return Math.min((props.currentRound / props.maxRounds) * 100, 100)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.board-banner-card {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--accent-board);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__body {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__icon {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__experts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--accent-board-soft);
|
||||||
|
color: var(--accent-board);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__chip--moderator {
|
||||||
|
background: var(--accent-board);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__chip-avatar {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__progress {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-banner-card__progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-board);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -31,7 +31,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||||
// Board Meeting state (transient, only active during a board discussion)
|
// Board Meeting state (transient, only active during a board discussion)
|
||||||
const boardState = ref<{
|
const boardState = ref<{
|
||||||
topic: string
|
topic: string
|
||||||
experts: Array<{ name: string; avatar: string; color: string; is_moderator: boolean }>
|
experts: Array<{ name: string; avatar: string; color: string; is_moderator: boolean; persona: string }>
|
||||||
max_rounds: number
|
max_rounds: number
|
||||||
current_round: number
|
current_round: number
|
||||||
status: 'discussing' | 'concluding' | 'completed' | 'dissolved'
|
status: 'discussing' | 'concluding' | 'completed' | 'dissolved'
|
||||||
|
|
@ -56,7 +56,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||||
const data = await apiClient.getConversations()
|
const data = await apiClient.getConversations()
|
||||||
// Normalize server response: backend returns {id, created_at, updated_at, message_count}
|
// Normalize server response: backend returns {id, created_at, updated_at, message_count}
|
||||||
// but frontend IConversation expects {id, title, messages, created_at, updated_at}
|
// but frontend IConversation expects {id, title, messages, created_at, updated_at}
|
||||||
conversations.value = data.map((conv: any) => ({
|
conversations.value = data.map((conv: IConversation) => ({
|
||||||
id: conv.id,
|
id: conv.id,
|
||||||
title: conv.title || '对话',
|
title: conv.title || '对话',
|
||||||
messages: Array.isArray(conv.messages) ? conv.messages : [],
|
messages: Array.isArray(conv.messages) ? conv.messages : [],
|
||||||
|
|
@ -276,7 +276,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||||
|
|
||||||
socket.onmessage = (event: MessageEvent) => {
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data as string) as Record<string, any>
|
const data = JSON.parse(event.data as string) as Record<string, unknown>
|
||||||
console.log('[Chat WS] Received:', data.type, data)
|
console.log('[Chat WS] Received:', data.type, data)
|
||||||
handleWsMessage(data)
|
handleWsMessage(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -403,6 +403,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||||
return _teamStore
|
return _teamStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: refactor to WsServerMessage union to eliminate `any`.
|
||||||
|
// This function predates the current VI redesign and touches many legacy branches.
|
||||||
function handleWsMessage(data: Record<string, any>): void {
|
function handleWsMessage(data: Record<string, any>): void {
|
||||||
// Backend sends nested data: {type, data: {...}}
|
// Backend sends nested data: {type, data: {...}}
|
||||||
// Flatten for easier access
|
// Flatten for easier access
|
||||||
|
|
@ -700,6 +702,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||||
avatar: e.avatar,
|
avatar: e.avatar,
|
||||||
color: e.color,
|
color: e.color,
|
||||||
is_moderator: e.is_moderator,
|
is_moderator: e.is_moderator,
|
||||||
|
persona: e.persona,
|
||||||
})),
|
})),
|
||||||
max_rounds: data.max_rounds,
|
max_rounds: data.max_rounds,
|
||||||
current_round: 0,
|
current_round: 0,
|
||||||
|
|
@ -708,18 +711,18 @@ export const useChatStore = defineStore('chat', () => {
|
||||||
streamingSteps.value.push(
|
streamingSteps.value.push(
|
||||||
`私董会已开启: 主题「${data.topic}」, ${data.experts.length} 位专家, 最多 ${data.max_rounds} 轮`
|
`私董会已开启: 主题「${data.topic}」, ${data.experts.length} 位专家, 最多 ${data.max_rounds} 轮`
|
||||||
)
|
)
|
||||||
// Push a system-style message to indicate board start
|
// Push a structured banner message so the renderer can show BoardBannerCard
|
||||||
const conversationId = currentConversationId.value
|
const conversationId = currentConversationId.value
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
const startMsg: IChatMessage = {
|
const startMsg: IChatMessage = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `🏛️ **私董会开始**\n\n**主题**: ${data.topic}\n**专家**: ${data.experts
|
content: `🏛️ 私董会开始:${data.topic}`,
|
||||||
.map((e) => `${e.avatar} ${e.name}${e.is_moderator ? ' (主持人)' : ''}`)
|
|
||||||
.join(', ')}\n**最大轮次**: ${data.max_rounds}`,
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
message_type: 'milestone',
|
message_type: 'board_started',
|
||||||
|
board_started: data,
|
||||||
|
board_round: 0,
|
||||||
}
|
}
|
||||||
appendMessage(conversationId, startMsg)
|
appendMessage(conversationId, startMsg)
|
||||||
}
|
}
|
||||||
|
|
@ -789,9 +792,21 @@ export const useChatStore = defineStore('chat', () => {
|
||||||
streamingSteps.value.push(
|
streamingSteps.value.push(
|
||||||
`私董会结束: ${data.total_rounds} 轮讨论${data.error ? ' (异常)' : ''}`
|
`私董会结束: ${data.total_rounds} 轮讨论${data.error ? ' (异常)' : ''}`
|
||||||
)
|
)
|
||||||
// The final_answer event will carry the formatted conclusion,
|
// Push a structured conclusion message so the renderer can show BoardConclusionCard
|
||||||
// so we don't need to add a separate message here.
|
const conversationId = currentConversationId.value
|
||||||
// The conclusion is already persisted by the backend.
|
if (conversationId) {
|
||||||
|
const conclusionMsg: IChatMessage = {
|
||||||
|
id: generateId(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.summary || '私董会已结束',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'completed',
|
||||||
|
message_type: 'board_conclusion',
|
||||||
|
board_conclusion: data,
|
||||||
|
board_round: data.total_rounds,
|
||||||
|
}
|
||||||
|
appendMessage(conversationId, conclusionMsg)
|
||||||
|
}
|
||||||
// Clear board state after a short delay to allow UI to update
|
// Clear board state after a short delay to allow UI to update
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
boardState.value = null
|
boardState.value = null
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue