feat(ui): private board restrictions + scheme B assistant/user bubbles
Test / backend-test (pull_request) Has been cancelled Details
Test / frontend-unit (pull_request) Has been cancelled Details
Test / api-e2e (pull_request) Has been cancelled Details
Test / frontend-e2e (pull_request) Has been cancelled Details

U1: ChatInput @board button blocks existing-conversation board creation
    with modal — enforces "one board per conversation" constraint.
U2: BoardBannerCard simplified to plain title + round meta
    (no icons/bars/progress/expert chips).
U3: MessageShell assistant bubble (方案B neutral grayscale) with
    F4-A card-type exclusion + G1 empty-bubble hide.
U4: UserBubble dark text bubble for plain text
    (command card/file keep light bg).

Code review fixes (ce-code-review step 5):
- P1: UserBubble focus-visible --accent-primary → --color-primary
  (dark mode visibility fix).
- P2: CARD_BEARING_TYPES adds 'error' (ErrorCard double-bubble regression).
- P2: Remove dead expertColor prop (scheme B leftover).
- P0/P1: Extract bubbleUtils.ts pure functions + add 42 tests
  covering G1/F4-A/U4/U2 key decisions.

Tests: 180/181 pass (1 pre-existing tauri-auth failure unrelated).
Typecheck: clean.
This commit is contained in:
chiguyong 2026-07-03 01:47:37 +08:00
parent 981a794a54
commit cc6634b2ab
12 changed files with 459 additions and 131 deletions

View File

@ -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.

View File

@ -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()

View File

@ -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)
}

View File

@ -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,
},

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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',

View File

@ -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;

View File

@ -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('私董会 — ')
})
})

View File

@ -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)
})
})

View File

@ -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>