agent-team/web/src/components/ChatView.tsx
sdaduanbilei fe1a82bbe2 feat: Plan/Build 模式、artifact 系统、成员直接对话
- 新增 Plan/Build 模式切换(Tab 键),Plan 模式阻止任务执行
- Build 模式:成员输出智能判断,文档存 artifact,提问显示聊天
- 成员可直接与用户对话(多轮),不经过 master 传话
- 任务计划文档自动生成,沟通记录自动追加
- 右侧面板重构为产出物面板,支持查看/编辑/保存
- 输入框改为 textarea,支持 Shift+Enter 换行,修复输入法 Enter 误发送
- Master 会话历史持久化,支持多轮上下文
- parseAssignments 支持多行任务描述
- SOUL.md 热重载、skill 递归发现与内容注入
- 需求确认 skill(HARD-GATE 模式)
- air 热重载配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:34:44 +08:00

488 lines
20 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
Hash,
Crown,
Send,
AtSign,
Copy,
Check,
FileText,
ExternalLink,
} from 'lucide-react'
import { useStore } from '../store'
import type { Message } from '../types'
export function ChatView() {
const { activeRoomId, rooms, messages, sendMessage, user } = useStore()
const [input, setInput] = useState('')
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
const [mentionIndex, setMentionIndex] = useState(0)
const inputRef = useRef<HTMLTextAreaElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const room = rooms.find(r => r.id === activeRoomId)
const msgs: Message[] = activeRoomId ? (messages[activeRoomId] || []) : []
const mode = room?.mode || 'plan'
// Build agent list for @ mentions
const mentionCandidates = room
? [room.master, ...(room.members || [])].filter(Boolean)
: []
const filteredMentions = mentionQuery !== null
? mentionCandidates.filter(name =>
name.toLowerCase().includes(mentionQuery.toLowerCase())
)
: []
// Detect @ trigger from input
const updateMention = useCallback((value: string, cursorPos: number) => {
const before = value.slice(0, cursorPos)
const atMatch = before.match(/@(\w*)$/)
if (atMatch) {
setMentionQuery(atMatch[1])
setMentionIndex(0)
} else {
setMentionQuery(null)
}
}, [])
const insertMention = useCallback((name: string) => {
const el = inputRef.current
if (!el) return
const cursorPos = el.selectionStart || input.length
const before = input.slice(0, cursorPos)
const after = input.slice(cursorPos)
const atIdx = before.lastIndexOf('@')
const newInput = before.slice(0, atIdx) + `@${name} ` + after
setInput(newInput)
setMentionQuery(null)
setTimeout(() => {
const pos = atIdx + name.length + 2
el.setSelectionRange(pos, pos)
el.focus()
})
}, [input])
const toggleMode = useCallback(() => {
if (!activeRoomId) return
const newMode = mode === 'build' ? 'plan' : 'build'
// 发送到后端
const { ws } = useStore.getState()
const socket = ws[activeRoomId]
if (socket) {
socket.send(JSON.stringify({ type: 'set_mode', mode: newMode }))
}
// 本地立即更新
useStore.setState(s => ({
rooms: s.rooms.map(r => r.id === activeRoomId ? { ...r, mode: newMode } : r)
}))
}, [activeRoomId, mode])
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [msgs, room?.status])
if (!room) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-[var(--bg-primary)] text-[var(--text-muted)]">
<Hash className="w-16 h-16 mb-4 opacity-20" />
<p className="text-lg"></p>
</div>
)
}
const statusLabel = room.status === 'working' && room.activeAgent
? `${room.activeAgent} ${room.action || ''}`
: room.status === 'thinking' ? '思考中...' : room.status === 'working' ? '工作中...' : '空闲'
return (
<div className="flex flex-1 overflow-hidden bg-[var(--bg-primary)]">
{/* Main chat area */}
<div className="flex flex-col flex-1 min-w-0">
{/* Header */}
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center gap-2 bg-[var(--bg-secondary)] shadow-sm">
<Hash className="w-5 h-5 text-[var(--text-muted)]" />
<span className="font-semibold text-base">{room.name}</span>
<div className="h-6 w-[1px] bg-[var(--border)] mx-2" />
<span className="text-sm text-[var(--text-muted)] truncate">
{room.team || '项目群'}
</span>
<div className="flex-1" />
{/* Status badge */}
<span
className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${
room.status === 'pending' ? 'bg-[var(--bg-tertiary)] text-[var(--text-muted)]' :
room.status === 'thinking' ? 'bg-[var(--color-warning)]/20 text-[var(--color-warning)]' :
'bg-[var(--color-success)]/20 text-[var(--color-success)]'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${
room.status === 'pending' ? 'bg-[var(--text-muted)]' :
room.status === 'thinking' ? 'bg-[var(--color-warning)] animate-pulse' :
'bg-[var(--color-success)] animate-pulse'
}`} />
{statusLabel}
</span>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-1 scrollbar-thin">
{msgs.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-[var(--text-muted)]">
<Hash className="w-12 h-12 mb-2 opacity-30" />
<p className="text-sm"> {room.name} </p>
<p className="text-xs mt-1"></p>
</div>
)}
{msgs.map(msg =>
msg.role === 'artifact' ? (
<ArtifactCard key={msg.id} msg={msg} roomId={activeRoomId!} />
) : (
<MessageBubble key={msg.id} msg={msg} userName={user?.name} />
)
)}
{/* Thinking indicator */}
{room.status === 'thinking' && msgs[msgs.length - 1]?.role === 'user' && (
<ThinkingBubble agent={room.master} />
)}
<div ref={bottomRef} />
</div>
{/* Input area */}
<div className="px-4 pb-4">
<div className="relative">
{/* @ Mention dropdown */}
{mentionQuery !== null && filteredMentions.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-lg overflow-hidden z-10">
<div className="px-3 py-1.5 text-[10px] font-semibold text-[var(--text-muted)] uppercase">
</div>
{filteredMentions.map((name, i) => {
const isMaster = name === room.master
return (
<button
key={name}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors ${
i === mentionIndex
? 'bg-[var(--accent)] text-white'
: 'hover:bg-[var(--bg-hover)]'
}`}
onMouseEnter={() => setMentionIndex(i)}
onMouseDown={e => {
e.preventDefault()
insertMention(name)
}}
>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-semibold flex-shrink-0 ${
isMaster ? 'bg-[var(--color-warning)]' : 'bg-[var(--accent)]'
}`}>
{isMaster ? <Crown className="w-3 h-3" /> : name[0]?.toUpperCase()}
</div>
<span className="truncate">{name}</span>
{isMaster && (
<span className={`text-[10px] px-1 py-0.5 rounded font-medium ml-auto ${
i === mentionIndex
? 'bg-white/20 text-white'
: 'bg-[var(--color-warning)]/20 text-[var(--color-warning)]'
}`}>
MASTER
</span>
)}
</button>
)
})}
</div>
)}
{/* Input bar with mode toggle */}
<div className="bg-[var(--bg-tertiary)] rounded-lg flex items-center gap-0 px-2 py-1.5">
{/* Mode toggle - OpenCode style */}
<button
onClick={toggleMode}
className={`
flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-bold tracking-wider transition-all duration-200 select-none flex-shrink-0
${mode === 'build'
? 'bg-orange-500 text-white shadow-sm shadow-orange-500/30'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
}
`}
title="Tab 切换模式"
>
{mode === 'build' ? 'BUILD' : 'PLAN'}
</button>
<div className="h-5 w-[1px] bg-[var(--border)] mx-2 flex-shrink-0" />
<textarea
ref={inputRef}
rows={1}
className="flex-1 bg-transparent outline-none text-sm placeholder:text-[var(--text-muted)] min-w-0 resize-none max-h-[120px] leading-5 py-0.5"
placeholder={mode === 'build' ? '发送确认开始执行任务...' : `#${room.name} 中发送消息...`}
value={input}
onChange={e => {
setInput(e.target.value)
// 自动调整高度
const el = e.target
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
updateMention(e.target.value, e.target.selectionStart || 0)
}}
onKeyDown={e => {
// 输入法正在组合(选字)时,不拦截任何按键
if (e.nativeEvent.isComposing) return
// 提及下拉菜单键盘控制
if (mentionQuery !== null && filteredMentions.length > 0) {
if (e.key === 'ArrowUp') {
e.preventDefault()
setMentionIndex(i => (i - 1 + filteredMentions.length) % filteredMentions.length)
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setMentionIndex(i => (i + 1) % filteredMentions.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
insertMention(filteredMentions[mentionIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setMentionQuery(null)
return
}
}
// Tab 切换模式(无提及下拉时)
if (e.key === 'Tab') {
e.preventDefault()
toggleMode()
return
}
// Shift+Enter 换行(默认行为,不拦截)
// Enter 发送消息
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (input.trim()) {
sendMessage(room.id, input.trim())
setInput('')
setMentionQuery(null)
// 重置高度
const el = inputRef.current
if (el) el.style.height = 'auto'
}
}
}}
/>
<button
className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors p-1.5"
title="@ 提及"
onClick={() => {
setInput(prev => prev + '@')
setMentionQuery('')
setMentionIndex(0)
inputRef.current?.focus()
}}
>
<AtSign className="w-5 h-5" />
</button>
<button
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors disabled:opacity-50 p-1.5"
disabled={!input.trim()}
onClick={() => {
if (input.trim()) {
sendMessage(room.id, input.trim())
setInput('')
setMentionQuery(null)
}
}}
>
<Send className="w-5 h-5" />
</button>
</div>
{/* Tab hint */}
<div className="flex items-center gap-2 mt-1 px-1">
<span className="text-[10px] text-[var(--text-muted)] opacity-60">
<kbd className="px-1 py-0.5 bg-[var(--bg-tertiary)] rounded text-[9px] border border-[var(--border)]">Tab</kbd>
</span>
</div>
</div>
</div>
</div>
</div>
)
}
function highlightMentions(content: string): string {
if (!content) return ''
return content.replace(/@(\w+)/g, '**@$1**')
}
/* ---- Artifact Card ---- */
function ArtifactCard({ msg, roomId }: { msg: Message; roomId: string }) {
const [copied, setCopied] = useState(false)
const avatarColors = [
'bg-red-500', 'bg-orange-500', 'bg-amber-500', 'bg-yellow-500',
'bg-lime-500', 'bg-green-500', 'bg-emerald-500', 'bg-teal-500',
'bg-cyan-500', 'bg-sky-500', 'bg-blue-500', 'bg-indigo-500',
'bg-violet-500', 'bg-purple-500', 'bg-fuchsia-500', 'bg-pink-500', 'bg-rose-500'
]
const agentName = msg.agent || 'Unknown'
const colorIndex = agentName.charCodeAt(0) % avatarColors.length
const openArtifact = () => {
if (!msg.filename) return
const event = new CustomEvent('open-artifact', { detail: { roomId, filename: msg.filename } })
window.dispatchEvent(event)
}
const copyFilename = () => {
navigator.clipboard.writeText(msg.filename || '')
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 mt-0.5 ${avatarColors[colorIndex]}`}>
{agentName[0]?.toUpperCase()}
</div>
<div className="flex flex-col items-start max-w-[70%]">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium text-sm text-[var(--text-primary)]">{agentName}</span>
<span className="text-[10px] px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 rounded font-medium">
</span>
</div>
<button
onClick={openArtifact}
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-lg p-3 hover:border-[var(--accent)] transition-colors text-left group"
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-[var(--accent)]/10 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-[var(--accent)]" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-[var(--text-primary)] truncate">
{msg.title || msg.filename}
</div>
<div className="text-xs text-[var(--text-muted)] mt-0.5">{msg.filename}</div>
</div>
<ExternalLink className="w-4 h-4 text-[var(--text-muted)] opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-1" />
</div>
</button>
<div className="flex items-center gap-1 mt-0.5">
<button
onClick={openArtifact}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-[var(--text-muted)] hover:text-[var(--accent)] hover:bg-[var(--bg-hover)] transition-colors"
>
<FileText className="w-3 h-3" />
</button>
<button
onClick={copyFilename}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] transition-colors"
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
</div>
)
}
/* ---- Message Bubble ---- */
function MessageBubble({ msg, userName }: { msg: Message; userName?: string }) {
const isUser = msg.role === 'user'
const isMaster = msg.role === 'master'
const agentName = msg.agent || 'Unknown'
const [copied, setCopied] = useState(false)
const avatarColors = [
'bg-red-500', 'bg-orange-500', 'bg-amber-500', 'bg-yellow-500',
'bg-lime-500', 'bg-green-500', 'bg-emerald-500', 'bg-teal-500',
'bg-cyan-500', 'bg-sky-500', 'bg-blue-500', 'bg-indigo-500',
'bg-violet-500', 'bg-purple-500', 'bg-fuchsia-500', 'bg-pink-500', 'bg-rose-500'
]
const colorIndex = agentName.charCodeAt(0) % avatarColors.length
const displayName = isUser ? (userName || agentName) : agentName
const copyText = (text: string) => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className={`flex gap-3 group ${isUser ? 'flex-row-reverse' : ''}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 mt-0.5 ${isUser ? 'bg-[var(--accent)]' : avatarColors[colorIndex]}`}>
{isMaster ? <Crown className="w-5 h-5" /> : displayName[0]?.toUpperCase()}
</div>
<div className={`flex flex-col max-w-[70%] ${isUser ? 'items-end' : 'items-start'}`}>
<div className={`flex items-center gap-2 mb-0.5 ${isUser ? 'flex-row-reverse' : ''}`}>
<span className={`font-medium text-sm ${isMaster ? 'text-[var(--color-warning)]' : isUser ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
{displayName}
</span>
{isMaster && (
<span className="text-[10px] px-1 py-0.5 bg-[var(--color-warning)]/20 text-[var(--color-warning)] rounded font-medium">
MASTER
</span>
)}
</div>
<div
className={`
px-3.5 py-2 rounded-lg text-sm relative
${isUser
? 'bg-[var(--accent)] text-white rounded-tr-sm'
: isMaster
? 'bg-[var(--bg-tertiary)] border-l-4 border-[var(--color-warning)] rounded-tl-sm'
: 'bg-[var(--bg-tertiary)] rounded-tl-sm'
}
`}
>
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{highlightMentions(msg.content)}
</ReactMarkdown>
</div>
{msg.streaming && (
<span className="inline-block w-1.5 h-3 bg-[var(--text-muted)] animate-pulse ml-0.5" />
)}
</div>
{!isUser && !msg.streaming && msg.content && (
<div className="flex items-center gap-1 mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => copyText(msg.content)}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] transition-colors"
title="复制消息"
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
{copied ? '已复制' : '复制'}
</button>
</div>
)}
</div>
</div>
)
}
function ThinkingBubble({ agent }: { agent: string }) {
return (
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--color-warning)]/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<Crown className="w-5 h-5 text-[var(--color-warning)]" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium text-sm text-[var(--color-warning)] mb-0.5">{agent || 'Master'}</span>
<div className="px-3.5 py-2.5 rounded-lg bg-[var(--bg-tertiary)] border-l-4 border-[var(--color-warning)] rounded-tl-sm flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)] animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)] animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)] animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)
}