feat(ui): private board restrictions + scheme B assistant/user bubbles #19
|
|
@ -70,9 +70,10 @@
|
|||
专家团
|
||||
</a-button>
|
||||
<a-button
|
||||
ref="boardButtonRef"
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
@click="showBoardModal = true"
|
||||
@click="handleBoardClick"
|
||||
class="chat-input__action-btn chat-input__action-btn--board"
|
||||
>
|
||||
<template #icon><TeamOutlined /></template>
|
||||
|
|
@ -125,6 +126,16 @@
|
|||
<span class="chat-input__hint">Enter 发送,Shift + Enter 换行</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-modal
|
||||
v-model:open="showBoardBlockModal"
|
||||
title="当前会话已存在私董会"
|
||||
ok-text="新建会话"
|
||||
cancel-text="我知道了"
|
||||
@ok="handleCreateNewConversationForBoard"
|
||||
@cancel="handleBoardBlockCancel"
|
||||
>
|
||||
<p>请新建会话来创建新的私董会</p>
|
||||
</a-modal>
|
||||
<BoardMeetingModal
|
||||
v-model:open="showBoardModal"
|
||||
@submit="handleBoardSubmit"
|
||||
|
|
@ -137,8 +148,8 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, type Component } from 'vue'
|
||||
import { Input as AInput, Button as AButton, Select as ASelect } from 'ant-design-vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type Component } from 'vue'
|
||||
import { Input as AInput, Button as AButton, Select as ASelect, Modal as AModal } from 'ant-design-vue'
|
||||
import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined, PoweroffOutlined, CommentOutlined } from '@ant-design/icons-vue'
|
||||
import ContextPill from './ContextPill.vue'
|
||||
import MentionDropdown from './MentionDropdown.vue'
|
||||
|
|
@ -198,6 +209,8 @@ const availableModels = ref<ModelInfo[]>([])
|
|||
const modelsLoading = ref(false)
|
||||
const showBoardModal = ref(false)
|
||||
const showTeamModal = ref(false)
|
||||
const showBoardBlockModal = ref(false)
|
||||
const boardButtonRef = ref<InstanceType<typeof AButton> | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const fileUploading = ref(false)
|
||||
const isDragOver = ref(false)
|
||||
|
|
@ -329,6 +342,32 @@ function handleStop(): void {
|
|||
emit('stop')
|
||||
}
|
||||
|
||||
function handleBoardClick(): void {
|
||||
// U1: 拦截"当前会话已存在进行中的私董会"场景
|
||||
const status = chatStore.boardState?.status
|
||||
if (status === 'discussing' || status === 'concluding') {
|
||||
showBoardBlockModal.value = true
|
||||
return
|
||||
}
|
||||
showBoardModal.value = true
|
||||
}
|
||||
|
||||
async function handleCreateNewConversationForBoard(): Promise<void> {
|
||||
showBoardBlockModal.value = false
|
||||
chatStore.createConversation()
|
||||
await chatStore.selectConversation(chatStore.currentConversationId!, true)
|
||||
// 不自动打开 BoardMeetingModal,由用户在新会话中再次点击"私董会"按钮继续
|
||||
}
|
||||
|
||||
function handleBoardBlockCancel(): void {
|
||||
// R4-D3: 焦点恢复 — a-modal 关闭后将焦点返回触发按钮(WAI-ARIA modal 对话模式)
|
||||
showBoardBlockModal.value = false
|
||||
nextTick(() => {
|
||||
const el = boardButtonRef.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleBoardSubmit(command: string): void {
|
||||
// The BoardMeetingModal constructs an @board:expert1,expert2 topic command.
|
||||
// Send it through the normal chat pipeline — the backend intercepts @board prefix.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
:avatar="spec.shell.avatar"
|
||||
:color="spec.shell.color"
|
||||
:expert-name="message.expert_name"
|
||||
:expert-color="message.expert_color"
|
||||
:streaming="message.status === 'streaming'"
|
||||
:message-type="spec.type"
|
||||
:is-empty="isBubbleEmpty"
|
||||
>
|
||||
<component
|
||||
:is="spec.component"
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
import MessageShell from './messages/MessageShell.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useMessageRenderer } from './helpers/useMessageRenderer'
|
||||
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
|
||||
import { useChatStore } from '@/stores/chatStore'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
|
|
@ -41,6 +43,12 @@ const componentListeners = computed(() =>
|
|||
spec.value.type === 'error' ? { retry: handleRetry } : {}
|
||||
)
|
||||
|
||||
/**
|
||||
* U3 G1: 检测消息内容是否为空 (pre-stream / tool-call-only)。
|
||||
* 逻辑提取到 bubbleUtils.isAssistantBubbleEmpty 以支持单元测试。
|
||||
*/
|
||||
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
|
||||
|
||||
function handleRetry(): void {
|
||||
if (chatStore.isLoading) return
|
||||
chatStore.resendLastUserMessage()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
/**
|
||||
* F4-A (Round 4 扩展): card-bearing assistant 类型排除气泡。
|
||||
* 这些类型由各自 card 组件自带 chrome (ErrorCard full chrome + 其余 partial chrome)。
|
||||
* 注: debate_started 对应 DebateBannerCard (plan 文档误记为 debate_banner)。
|
||||
* ce-code-review P2: 加入 'error' — ErrorCard.vue 自带 full chrome,
|
||||
* 不排除会导致 assistant 气泡嵌套 ErrorCard chrome 双层背景。
|
||||
*
|
||||
* 提取为纯函数以支持单元测试 (F4-A 是关键决策),避免 leaky abstraction
|
||||
* (ce-simplify-code P3) — MessageShell 通过函数调用而非暴露 Set。
|
||||
*/
|
||||
const CARD_BEARING_TYPES = new Set<string>([
|
||||
'board_conclusion',
|
||||
'team_plan',
|
||||
'debate_started',
|
||||
'debate_argument',
|
||||
'debate_summary',
|
||||
'debate_resolved',
|
||||
'collaboration_graph',
|
||||
'review_result',
|
||||
'risk_flagged',
|
||||
'error',
|
||||
])
|
||||
|
||||
export function isCardBearingType(type?: string): boolean {
|
||||
return type ? CARD_BEARING_TYPES.has(type) : false
|
||||
}
|
||||
|
||||
/**
|
||||
* U3 G1: 检测 assistant 消息内容是否为空 (pre-stream / tool-call-only)。
|
||||
* AssistantText 总渲染根 div,:empty 选择器永不匹配,需 JS 判断。
|
||||
* isEmpty = 无 content + 无 thinking + 无 tool_calls。
|
||||
*
|
||||
* 提取为纯函数以支持单元测试 (G1 是 P0 决策)。
|
||||
*/
|
||||
export function isAssistantBubbleEmpty(message: IChatMessage): boolean {
|
||||
if (message.role !== 'assistant') return false
|
||||
return (
|
||||
!message.content &&
|
||||
!message.thinking &&
|
||||
(!message.tool_calls || message.tool_calls.length === 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* U4: 判定用户消息是否为普通文本 (非文件附件且非 @board/@team 命令)。
|
||||
* 仅普通文本应用深色气泡,command card / file attachment 保持浅色背景。
|
||||
*
|
||||
* 提取为纯函数以支持单元测试 (U4 是关键功能)。
|
||||
*/
|
||||
const FILE_MARKDOWN_RE = /^\[文件\]\s*\[(.+?)\]\((.+?)\)$/s
|
||||
const COMMAND_RE = /^@(board|team)(?::([^\s]+))?\s+([\s\S]+)$/
|
||||
|
||||
export function isPlainUserText(content: string): boolean {
|
||||
return !FILE_MARKDOWN_RE.test(content) && !COMMAND_RE.test(content)
|
||||
}
|
||||
|
|
@ -135,14 +135,12 @@ export function useMessageRenderer(message: IChatMessage) {
|
|||
|
||||
case 'board_banner': {
|
||||
const data = message.board_started
|
||||
const experts = data?.experts ?? []
|
||||
return {
|
||||
type,
|
||||
shell: { name: '私董会', meta: time },
|
||||
component: BoardBannerCard,
|
||||
props: {
|
||||
topic: data?.topic || message.content || '未命名主题',
|
||||
experts,
|
||||
maxRounds: data?.max_rounds ?? 5,
|
||||
currentRound: message.board_round ?? 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,138 +1,37 @@
|
|||
<template>
|
||||
<div class="board-banner-card">
|
||||
<div class="board-banner-card__bar" />
|
||||
<div class="board-banner-card__body">
|
||||
<div class="board-banner-card__title">
|
||||
<BankOutlined class="board-banner-card__icon" />
|
||||
<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 class="board-banner-card__title">私董会 — {{ topic }}</div>
|
||||
<div class="board-banner-card__meta">轮次:第 {{ currentRound }} / {{ maxRounds }} 轮</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BankOutlined } from '@ant-design/icons-vue'
|
||||
import type { IBoardExpert } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
topic: string
|
||||
experts: IBoardExpert[]
|
||||
maxRounds: number
|
||||
currentRound?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<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);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<div class="message-shell" :class="[`message-shell--${role}`]">
|
||||
<div
|
||||
class="message-shell"
|
||||
:class="[
|
||||
`message-shell--${role}`,
|
||||
{ 'message-shell--card': isCardBearing, 'message-shell--empty': isEmpty },
|
||||
]"
|
||||
>
|
||||
<div class="message-shell__avatar">
|
||||
<slot name="avatar">
|
||||
<div
|
||||
|
|
@ -42,9 +48,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Avatar as AAvatar } from 'ant-design-vue'
|
||||
import { RobotOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import type { Component } from 'vue'
|
||||
import { isCardBearingType } from '../helpers/bubbleUtils'
|
||||
|
||||
interface Props {
|
||||
role: 'user' | 'assistant'
|
||||
|
|
@ -52,23 +60,28 @@ interface Props {
|
|||
meta?: string
|
||||
avatar?: string | Component
|
||||
color?: string
|
||||
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为彩色 badge 替代普通 name 文本 */
|
||||
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为粗体文本替代普通 name */
|
||||
expertName?: string
|
||||
/** U4 R10: 专家身份 badge 颜色 */
|
||||
expertColor?: string
|
||||
/** U4: 流式进行中 — 显示省略号指示器 */
|
||||
streaming?: boolean
|
||||
/** U3 F4-A: 消息类型 — card-bearing 类型不加气泡 (由 card 自带 chrome) */
|
||||
messageType?: string
|
||||
/** U3 G1: 消息内容为空 (pre-stream / tool-call-only) — 隐藏空气泡矩形 */
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
name: undefined,
|
||||
meta: undefined,
|
||||
avatar: undefined,
|
||||
color: undefined,
|
||||
expertName: undefined,
|
||||
expertColor: undefined,
|
||||
streaming: false,
|
||||
messageType: undefined,
|
||||
isEmpty: false,
|
||||
})
|
||||
|
||||
const isCardBearing = computed(() => isCardBearingType(props.messageType))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -186,4 +199,33 @@ withDefaults(defineProps<Props>(), {
|
|||
.message-shell--user .message-shell__content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* U3: assistant 浅灰圆角气泡 (方案B) — 仅 role=assistant 生效,user 不加气泡。
|
||||
F1-A: 用独立 token --bg-message-bubble (与 inline code/table 的 --bg-secondary 解耦)。
|
||||
不使用 !important,让 AssistantText 内部 pre/hljs/code/table 自然继承。 */
|
||||
.message-shell--assistant .message-shell__content {
|
||||
background: var(--bg-message-bubble);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* F4-A: card-bearing assistant 类型不加气泡 (共 9 种,Round 4 扩展)。
|
||||
这些类型由各自 card 组件自带 chrome (BoardConclusionCard/TeamPlanCard full chrome,
|
||||
其余 7 种 partial chrome),避免气泡嵌套冲突。 */
|
||||
.message-shell--assistant.message-shell--card .message-shell__content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* G1: 空内容 (pre-stream / tool-call-only) 隐藏气泡矩形,仅显示 thinking dots。
|
||||
替代 Round 1 D4-方案1 的 :empty (AssistantText 总渲染根 div 导致 :empty 永不匹配)。 */
|
||||
.message-shell--assistant.message-shell--empty .message-shell__content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
<div
|
||||
ref="rootRef"
|
||||
class="user-bubble"
|
||||
:class="{ 'user-bubble--focusable': msgId }"
|
||||
:class="{
|
||||
'user-bubble--focusable': msgId,
|
||||
'user-bubble--text': isPlainText,
|
||||
}"
|
||||
:tabindex="msgId ? 0 : undefined"
|
||||
@mouseenter="onBubbleMouseEnter"
|
||||
@mouseleave="onBubbleMouseLeave"
|
||||
|
|
@ -157,6 +160,14 @@ const fileAttachment = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* U4: 普通文本消息判定 — 非 file attachment 且非 @board/@team command card。
|
||||
* 仅普通文本应用深色气泡,command card / file attachment 保持浅色 --bg-tertiary。
|
||||
* 核心正则逻辑提取到 bubbleUtils.isPlainUserText 以支持单元测试;
|
||||
* 此处保留对 fileAttachment/commandBubble computed 的引用以维持响应性。
|
||||
*/
|
||||
const isPlainText = computed(() => !fileAttachment.value && !commandBubble.value)
|
||||
|
||||
// --- Action toolbar visibility ---
|
||||
// Three independent reasons the toolbar can stay open; the toolbar shows
|
||||
// while ANY is active. This avoids the classic hover-toolbar bug where
|
||||
|
|
@ -301,10 +312,20 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.user-bubble--focusable:focus-visible {
|
||||
outline: 2px solid var(--accent-primary, #1a1a1a);
|
||||
outline: 2px solid var(--color-primary, #1a1a1a);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* U4: 普通文本消息参考 demo 深色气泡 (--color-primary + --text-inverse)。
|
||||
command card / file attachment 保持 .user-bubble 默认 --bg-tertiary 浅色背景。
|
||||
--color-primary / --text-inverse 在 light/dark mode 下自动反转。 */
|
||||
.user-bubble--text {
|
||||
background: var(--color-primary);
|
||||
color: var(--text-inverse);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.user-bubble__text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
<MessageShell role="assistant" name="私董会" meta="11:00">
|
||||
<BoardBannerCard
|
||||
topic="是否将订单服务拆分为独立微服务"
|
||||
:experts="boardExperts"
|
||||
:max-rounds="3"
|
||||
:current-round="1"
|
||||
/>
|
||||
|
|
@ -41,13 +40,7 @@ import {
|
|||
BoardRoundCard,
|
||||
BoardConclusionCard,
|
||||
} from '@/components/chat/messages'
|
||||
import type { IChatMessage, IBoardConcludedData, IBoardExpert } from '@/api/types'
|
||||
|
||||
const boardExperts: IBoardExpert[] = [
|
||||
{ name: '架构师老张', avatar: '张', color: 'var(--accent-board)', is_moderator: false, persona: '架构' },
|
||||
{ name: '后端负责人小李', avatar: '李', color: 'var(--accent-board)', is_moderator: false, persona: '后端' },
|
||||
{ name: '主持人', avatar: '主', color: 'var(--accent-board)', is_moderator: true, persona: '主持' },
|
||||
]
|
||||
import type { IChatMessage, IBoardConcludedData } from '@/api/types'
|
||||
|
||||
const speech1: IChatMessage = {
|
||||
id: 's4-speech-1',
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@
|
|||
--bg-tertiary: #f7f7f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-code: #1e1e2e;
|
||||
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
|
||||
--bg-message-bubble: #ffffff;
|
||||
|
||||
/* ── Foreground / Text ── */
|
||||
--text-primary: #1a1a1a;
|
||||
|
|
@ -224,6 +226,8 @@
|
|||
--bg-tertiary: #2a2a2a;
|
||||
--bg-elevated: #252525;
|
||||
--bg-code: #11111b;
|
||||
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
|
||||
--bg-message-bubble: #1f1f1f;
|
||||
|
||||
/* ── Foreground / Text ── */
|
||||
--text-primary: #fbfbfa;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Unit tests for BoardBannerCard (U2 — simplified banner).
|
||||
*
|
||||
* Covers the P1 testing gap flagged by ce-code-review. Verifies:
|
||||
* - Renders "私董会 — {topic}" title
|
||||
* - Renders "轮次:第 N / M 轮" meta
|
||||
* - currentRound defaults to 1 when not provided
|
||||
* - Custom currentRound renders correctly
|
||||
* - No icons, borders, purple bars, progress bars, or expert chips (U2 simplification)
|
||||
*
|
||||
* Mount strategy: native Vue createApp + h (no @vue/test-utils dependency),
|
||||
* consistent with AssistantText.test.ts / ThinkingBlock.test.ts pattern.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, h, type App } from 'vue'
|
||||
import BoardBannerCard from '@/components/chat/messages/BoardBannerCard.vue'
|
||||
|
||||
interface Mounted {
|
||||
container: HTMLElement
|
||||
root: HTMLElement
|
||||
app: App
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function mountBoardBannerCard(props: Record<string, unknown>): Mounted {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const app = createApp({
|
||||
render: () => h(BoardBannerCard as never, props as never),
|
||||
})
|
||||
app.mount(container)
|
||||
const root = container.querySelector('.board-banner-card') as HTMLElement
|
||||
return { container, root, app, unmount: () => { app.unmount(); container.remove() } }
|
||||
}
|
||||
|
||||
describe('BoardBannerCard — U2 simplified banner', () => {
|
||||
let mounted: Mounted | null = null
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.unmount()
|
||||
mounted = null
|
||||
})
|
||||
|
||||
it('renders "私董会 — {topic}" as title', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '技术选型讨论', maxRounds: 5 })
|
||||
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
|
||||
expect(title).toBeTruthy()
|
||||
expect(title.textContent).toBe('私董会 — 技术选型讨论')
|
||||
})
|
||||
|
||||
it('renders "轮次:第 N / M 轮" as meta', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '架构评审', maxRounds: 3, currentRound: 2 })
|
||||
const meta = mounted.container.querySelector('.board-banner-card__meta') as HTMLElement
|
||||
expect(meta).toBeTruthy()
|
||||
expect(meta.textContent).toBe('轮次:第 2 / 3 轮')
|
||||
})
|
||||
|
||||
it('defaults currentRound to 1 when not provided', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '新产品方向', maxRounds: 5 })
|
||||
const meta = mounted.container.querySelector('.board-banner-card__meta') as HTMLElement
|
||||
expect(meta.textContent).toBe('轮次:第 1 / 5 轮')
|
||||
})
|
||||
|
||||
it('renders only title and meta — no icons, bars, progress, or expert chips', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '精简测试', maxRounds: 4, currentRound: 2 })
|
||||
// Only two child elements: __title and __meta
|
||||
const children = mounted.root.children
|
||||
expect(children.length).toBe(2)
|
||||
expect(children[0].className).toBe('board-banner-card__title')
|
||||
expect(children[1].className).toBe('board-banner-card__meta')
|
||||
// No icon elements, progress bars, or expert chips
|
||||
expect(mounted.root.querySelector('svg, .anticon, .board-banner-card__bar, .board-banner-card__progress, .board-banner-card__experts, .board-banner-card__chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles CJK topic correctly', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '人工智能伦理边界探讨', maxRounds: 6 })
|
||||
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
|
||||
expect(title.textContent).toBe('私董会 — 人工智能伦理边界探讨')
|
||||
})
|
||||
|
||||
it('handles empty topic gracefully', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '', maxRounds: 5 })
|
||||
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
|
||||
expect(title.textContent).toBe('私董会 — ')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Unit tests for bubbleUtils — pure functions extracted from MessageShell /
|
||||
* ChatMessage / UserBubble to support testing of key decisions:
|
||||
*
|
||||
* - isCardBearingType (F4-A): card-bearing assistant types skip bubble chrome
|
||||
* - isAssistantBubbleEmpty (G1): empty content detection replaces :empty selector
|
||||
* - isPlainUserText (U4): plain text vs file-attachment / @board/@team command
|
||||
*
|
||||
* These cover the P0/P1 testing gaps flagged by ce-code-review: G1 is a P0
|
||||
* decision (:empty never matches), F4-A is a P0 regression guard (double
|
||||
* bubble), U4 is a P0 feature (dark bubble only for plain text).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isCardBearingType,
|
||||
isAssistantBubbleEmpty,
|
||||
isPlainUserText,
|
||||
} from '@/components/chat/helpers/bubbleUtils'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
function makeMsg(overrides: Partial<IChatMessage> = {}): IChatMessage {
|
||||
return {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-07-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── isCardBearingType (F4-A) ────────────────────────────────────────
|
||||
|
||||
describe('isCardBearingType — F4-A card-bearing exclusion', () => {
|
||||
const cardBearingTypes = [
|
||||
'board_conclusion',
|
||||
'team_plan',
|
||||
'debate_started',
|
||||
'debate_argument',
|
||||
'debate_summary',
|
||||
'debate_resolved',
|
||||
'collaboration_graph',
|
||||
'review_result',
|
||||
'risk_flagged',
|
||||
'error',
|
||||
]
|
||||
|
||||
it.each(cardBearingTypes)('returns true for card-bearing type "%s"', (type) => {
|
||||
expect(isCardBearingType(type)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for plain assistant type', () => {
|
||||
expect(isCardBearingType('assistant')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for user type', () => {
|
||||
expect(isCardBearingType('user')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for milestone type (not card-bearing)', () => {
|
||||
expect(isCardBearingType('milestone')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for board_banner / board_speech / board_summary (partial chrome, no own bubble chrome)', () => {
|
||||
// These use BoardRoundCard which relies on the assistant bubble chrome,
|
||||
// so they must NOT be excluded.
|
||||
expect(isCardBearingType('board_banner')).toBe(false)
|
||||
expect(isCardBearingType('board_speech')).toBe(false)
|
||||
expect(isCardBearingType('board_summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined type', () => {
|
||||
expect(isCardBearingType(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isCardBearingType('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for unknown type', () => {
|
||||
expect(isCardBearingType('unknown_type')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── isAssistantBubbleEmpty (G1) ─────────────────────────────────────
|
||||
|
||||
describe('isAssistantBubbleEmpty — G1 empty content detection', () => {
|
||||
it('returns true for assistant with no content, no thinking, no tool_calls', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: '' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for assistant with undefined content and thinking', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: undefined, thinking: undefined })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for assistant with empty tool_calls array', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: '', tool_calls: [] })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for assistant with content', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: 'hello' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for assistant with thinking content', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: '', thinking: '思考中' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for assistant with non-empty tool_calls', () => {
|
||||
const msg = makeMsg({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{ id: 't1', name: 'search', arguments: '{}' } as never],
|
||||
})
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for user role even when content is empty', () => {
|
||||
const msg = makeMsg({ role: 'user', content: '' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for user role with content', () => {
|
||||
const msg = makeMsg({ role: 'user', content: 'hi' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── isPlainUserText (U4) ────────────────────────────────────────────
|
||||
|
||||
describe('isPlainUserText — U4 plain text detection', () => {
|
||||
it('returns true for plain text', () => {
|
||||
expect(isPlainUserText('你好,帮我重构这个函数')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for empty string (no command, no file)', () => {
|
||||
expect(isPlainUserText('')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for text with @ mention that is not board/team', () => {
|
||||
expect(isPlainUserText('@user hello')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for @board command', () => {
|
||||
expect(isPlainUserText('@board 讨论技术选型')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @team command', () => {
|
||||
expect(isPlainUserText('@team 完成需求分析')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @board with expert list', () => {
|
||||
expect(isPlainUserText('@board:expert1,expert2 讨论架构')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @team with template', () => {
|
||||
expect(isPlainUserText('@team:dev_team 实现登录功能')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @board with rounds param', () => {
|
||||
expect(isPlainUserText('@board rounds=3 讨论方案')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for file attachment markdown', () => {
|
||||
expect(isPlainUserText('[文件] [report.pdf](https://example.com/report.pdf)')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for text containing @board in the middle (not a prefix)', () => {
|
||||
// Only prefix @board/@team commands are recognized; mid-text @ is plain.
|
||||
expect(isPlainUserText('请帮我 @board 这个问题')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for multiline plain text', () => {
|
||||
expect(isPlainUserText('第一行\n第二行\n第三行')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fischer AgentKit</title>
|
||||
<script type="module" crossorigin src="/assets/index-CHN0ThS0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DC_0XUg6.css">
|
||||
<script type="module" crossorigin src="/assets/index-N9Dybwcy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BgFZbme0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue