- 新增 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>
488 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|