From 46e00d3fc9478f339d1eed751fbd48e7f95f532f Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Thu, 20 Aug 2026 20:48:02 +0800 Subject: [PATCH 1/5] =?UTF-8?q?UI=E4=BC=98=E5=8C=96=EF=BC=9A=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E6=B5=AE=E7=AA=97=E6=98=BE=E7=A4=BA=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E3=80=81=E5=85=A8=E5=B1=80=E7=BB=86=E6=BB=9A=E5=8A=A8=E6=9D=A1?= =?UTF-8?q?=E3=80=81=E4=BB=BB=E5=8A=A1=E9=A1=B5=E4=BE=A7=E6=A0=8F=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E3=80=81=E8=BE=93=E5=85=A5=E6=A1=86=E6=8D=A2=E8=A1=8C?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserAccountMenu: 头像浮窗显示可用积分(creditBalance,配额+赠送+购买-已用) - index.css: 全局滚动条统一变窄为5px细样式(scrollbar-hide除外) - Work: 任务页左上角增加侧边栏开关按钮(复用l1Collapsed状态) - Team/CommentInput: 输入框打字时显示Shift+Enter换行提示,Enter发送 - 删除无人引用的废弃组件ChatInput.tsx - 中英文locale补齐文案(creditBalance/shiftEnterHint/task.expand) --- packages/web-ui/src/components/ChatInput.tsx | 546 ------------------ .../web-ui/src/components/CommentInput.tsx | 6 + .../web-ui/src/components/UserAccountMenu.tsx | 24 + packages/web-ui/src/index.css | 23 + packages/web-ui/src/locales/en/common.json | 2 + packages/web-ui/src/locales/en/work.json | 1 + packages/web-ui/src/locales/zh-CN/common.json | 2 + packages/web-ui/src/locales/zh-CN/work.json | 1 + packages/web-ui/src/pages/Team.tsx | 6 + packages/web-ui/src/pages/Work.tsx | 20 + 10 files changed, 85 insertions(+), 546 deletions(-) delete mode 100644 packages/web-ui/src/components/ChatInput.tsx diff --git a/packages/web-ui/src/components/ChatInput.tsx b/packages/web-ui/src/components/ChatInput.tsx deleted file mode 100644 index 3ad66cab..00000000 --- a/packages/web-ui/src/components/ChatInput.tsx +++ /dev/null @@ -1,546 +0,0 @@ -import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type ClipboardEvent, type DragEvent } from 'react'; -import { createPortal } from 'react-dom'; -import { useTranslation } from 'react-i18next'; -import { Avatar } from './Avatar.tsx'; - -export type MentionEntityType = 'agent' | 'project' | 'requirement' | 'task' | 'deliverable' | 'workflow'; - -// ---- slash commands ---- -export type SlashCommandType = 'system' | 'skill'; - -export interface SlashCommand { - id: string; - name: string; - description: string; - type: SlashCommandType; - icon: string; -} - -export interface ContextChip { - id: string; - label: string; - type: 'selection' | 'mention' | 'deliverable' | 'task' | 'project' | 'requirement' | 'workflow'; - content: string; - entityType?: MentionEntityType; - onRemove?: () => void; -} - -export interface MentionItem { - id: string; - name: string; - role?: string; - avatarUrl?: string; - type: MentionEntityType; -} - -const MENTION_TYPE_ICON: Record = { - agent: '🤖', - project: '📁', - requirement: '📋', - task: '✅', - deliverable: '📦', - workflow: '⚙️', -}; - -const MENTION_TYPE_ORDER: MentionEntityType[] = ['project', 'requirement', 'task', 'deliverable', 'workflow', 'agent']; - -export interface PendingFile { - id: string; - dataUrl: string; - name: string; -} - -export interface MentionChip { - id: string; - entityId: string; - name: string; - entityType: MentionEntityType; -} - -export interface ChatInputProps { - value: string; - onChange: (value: string) => void; - onSend: () => void; - disabled?: boolean; - placeholder?: string; - sending?: boolean; - onStop?: () => void; - contextChips?: ContextChip[]; - mentionItems?: MentionItem[]; - showMentionDropdown?: boolean; - pendingFiles?: PendingFile[]; - onAttach?: () => void; - onPaste?: (e: ClipboardEvent) => void; - onDrop?: (e: DragEvent) => void; - onDragOver?: (e: DragEvent) => void; - onRemoveFile?: (id: string) => void; - replyTo?: { id: string; sender: string; text: string } | null; - onClearReply?: () => void; - fileInputRef?: React.RefObject; - /** 可选:外部 textarea ref(供父组件聚焦输入框,如打开右侧栏时自动聚焦)。 */ - textareaRef?: React.Ref; - visionWarning?: boolean; - maxFiles?: number; - className?: string; - compact?: boolean; - /** Called when mention chips change (add/remove) */ - onMentionChipsChange?: (chips: MentionChip[]) => void; - /** Available slash commands (skills) */ - slashCommands?: SlashCommand[]; -} - -const MAX_FILE_SIZE = 10 * 1024 * 1024; - -function isImageFile(f: { name: string; dataUrl: string }) { - return f.dataUrl.startsWith('data:image/'); -} - -function getFileIcon(name: string, dataUrl: string) { - if (isImageFile({ name, dataUrl })) return null; - const ext = name.split('.').pop()?.toLowerCase() ?? ''; - const iconMap: Record = { - pdf: '📄', docx: '📝', doc: '📝', xlsx: '📊', xls: '📊', - pptx: '📎', csv: '📊', json: '🔧', xml: '🔧', html: '🌐', epub: '📚', - }; - return iconMap[ext] ?? '📁'; -} - -export function ChatInput({ - value, - onChange, - onSend, - disabled = false, - placeholder = '', - sending = false, - onStop, - contextChips, - mentionItems, - pendingFiles, - onAttach, - onPaste, - onDrop, - onDragOver, - onRemoveFile, - replyTo, - onClearReply, - fileInputRef: externalFileInputRef, - textareaRef: externalTextareaRef, - visionWarning, - maxFiles = 5, - className = '', - compact = false, - onMentionChipsChange, - slashCommands, -}: ChatInputProps) { - const { t } = useTranslation(['team', 'common']); - const containerRef = useRef(null); - const textareaRef = useRef(null); - const internalFileRef = useRef(null); - const fileInputRef = externalFileInputRef ?? internalFileRef; - - const [mentionDropdown, setMentionDropdown] = useState(false); - const [mentionFilter, setMentionFilter] = useState(''); - const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0); - const [dropdownPos, setDropdownPos] = useState<{ left: number; bottom: number } | null>(null); - const [mentionChips, setMentionChips] = useState([]); - - // ---- slash command state ---- - const [slashDropdown, setSlashDropdown] = useState(false); - const [slashFilter, setSlashFilter] = useState(''); - const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); - - const filteredMentions = (mentionItems ?? []).filter(a => a.name.toLowerCase().includes(mentionFilter)); - - const groupedMentions = (() => { - if (filteredMentions.length === 0) return []; - const byType = new Map(); - for (const item of filteredMentions) { - const t = item.type ?? 'agent'; - if (!byType.has(t)) byType.set(t, []); - byType.get(t)!.push(item); - } - const groups: Array<{ type: MentionEntityType; items: MentionItem[] }> = []; - for (const t of MENTION_TYPE_ORDER) { - const items = byType.get(t); - if (items?.length) groups.push({ type: t, items }); - } - return groups; - })(); - - // ---- slash command filtering (flat list, skills only) ---- - const slashCmds = slashCommands ?? []; - const filteredSlashCmds = !slashFilter - ? slashCmds - : slashCmds.filter(c => c.name.toLowerCase().includes(slashFilter)); - - useEffect(() => { - if ((!mentionDropdown && !slashDropdown) || !containerRef.current) { setDropdownPos(null); return; } - const rect = containerRef.current.getBoundingClientRect(); - setDropdownPos({ left: rect.left + 16, bottom: window.innerHeight - rect.top + 4 }); - }, [mentionDropdown, slashDropdown]); - - const adjustTextareaHeight = useCallback(() => { - const el = textareaRef.current; - if (!el) return; - el.style.height = 'auto'; - el.style.height = `${Math.min(el.scrollHeight, compact ? 80 : 120)}px`; - }, [compact]); - - useEffect(() => { - adjustTextareaHeight(); - }, [value, adjustTextareaHeight]); - - const handleInputChange = useCallback((val: string) => { - onChange(val); - - // ---- @ mention detection ---- - if (mentionItems?.length) { - const cursorPos = textareaRef.current?.selectionStart ?? val.length; - const textBeforeCursor = val.slice(0, cursorPos); - const atIdx = textBeforeCursor.lastIndexOf('@'); - if (atIdx >= 0) { - const charBefore = atIdx === 0 ? '' : textBeforeCursor[atIdx - 1]!; - const isValidPosition = atIdx === 0 || /[\s\n,,。!?!?;;::、()()\[\]【】]/.test(charBefore); - if (isValidPosition) { - const fragment = textBeforeCursor.slice(atIdx + 1); - if (!fragment.includes(' ') && !fragment.includes('\n')) { - setMentionDropdown(true); - setMentionFilter(fragment.toLowerCase()); - setMentionSelectedIndex(0); - setSlashDropdown(false); - return; - } - } - } - } - setMentionDropdown(false); - - // ---- / slash command detection ---- - if (slashCmds.length > 0) { - const cursorPos = textareaRef.current?.selectionStart ?? val.length; - const textBeforeCursor = val.slice(0, cursorPos); - const slashIdx = textBeforeCursor.lastIndexOf('/'); - if (slashIdx >= 0) { - const charBefore = slashIdx === 0 ? '' : textBeforeCursor[slashIdx - 1]!; - const isValidPosition = slashIdx === 0 || /[\s\n,,。!?!?;;::、()()\[\]【】]/.test(charBefore); - if (isValidPosition) { - const fragment = textBeforeCursor.slice(slashIdx + 1); - if (!fragment.includes(' ') && !fragment.includes('\n')) { - setSlashDropdown(true); - setSlashFilter(fragment.toLowerCase()); - setSlashSelectedIndex(0); - return; - } - } - } - } - setSlashDropdown(false); - }, [onChange, mentionItems, slashCmds]); - - const insertMention = useCallback((item: MentionItem) => { - const cursorPos = textareaRef.current?.selectionStart ?? value.length; - const before = value.slice(0, cursorPos); - const atIdx = before.lastIndexOf('@'); - const after = value.slice(cursorPos); - const newVal = value.slice(0, atIdx) + after.replace(/^\s/, ''); - onChange(newVal); - - const chip: MentionChip = { - id: `mention_${Date.now()}_${item.id}`, - entityId: item.id, - name: item.name, - entityType: item.type, - }; - const nextChips = [...mentionChips, chip]; - setMentionChips(nextChips); - onMentionChipsChange?.(nextChips); - - setMentionDropdown(false); - setMentionSelectedIndex(0); - requestAnimationFrame(() => { - const pos = atIdx; - textareaRef.current?.setSelectionRange(pos, pos); - textareaRef.current?.focus(); - }); - }, [value, onChange, mentionChips, onMentionChipsChange]); - - const insertSlashCommand = useCallback((cmd: SlashCommand) => { - const cursorPos = textareaRef.current?.selectionStart ?? value.length; - const before = value.slice(0, cursorPos); - const slashIdx = before.lastIndexOf('/'); - const after = value.slice(cursorPos); - - // Insert the command name as text: /skill-name - const newVal = value.slice(0, slashIdx) + '/' + cmd.name + ' ' + after; - onChange(newVal); - setSlashDropdown(false); - setSlashSelectedIndex(0); - const newPos = slashIdx + cmd.name.length + 2; - requestAnimationFrame(() => { - textareaRef.current?.setSelectionRange(newPos, newPos); - textareaRef.current?.focus(); - }); - }, [value, onChange]); - - const handleSend = useCallback(() => { - onSend(); - setMentionChips([]); - onMentionChipsChange?.([]); - }, [onSend, onMentionChipsChange]); - - const handleKeyDown = useCallback((e: KeyboardEvent) => { - // ---- @ mention dropdown keys ---- - if (mentionDropdown && filteredMentions.length > 0) { - const isUp = e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p'); - const isDown = e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n'); - const isSelect = e.key === 'Enter' || e.key === 'Tab'; - const isClose = e.key === 'Escape'; - if (isUp) { e.preventDefault(); setMentionSelectedIndex(prev => (prev - 1 + filteredMentions.length) % filteredMentions.length); return; } - if (isDown) { e.preventDefault(); setMentionSelectedIndex(prev => (prev + 1) % filteredMentions.length); return; } - if (isSelect) { e.preventDefault(); const item = filteredMentions[mentionSelectedIndex]; if (item) insertMention(item); return; } - if (isClose) { e.preventDefault(); setMentionDropdown(false); return; } - } - - // ---- / slash command dropdown keys ---- - if (slashDropdown && filteredSlashCmds.length > 0) { - const isUp = e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p'); - const isDown = e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n'); - const isSelect = e.key === 'Enter' || e.key === 'Tab'; - const isClose = e.key === 'Escape'; - if (isUp) { e.preventDefault(); setSlashSelectedIndex(prev => (prev - 1 + filteredSlashCmds.length) % filteredSlashCmds.length); return; } - if (isDown) { e.preventDefault(); setSlashSelectedIndex(prev => (prev + 1) % filteredSlashCmds.length); return; } - if (isSelect) { e.preventDefault(); const cmd = filteredSlashCmds[slashSelectedIndex]; if (cmd) insertSlashCommand(cmd); return; } - if (isClose) { e.preventDefault(); setSlashDropdown(false); return; } - } - - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } - }, [mentionDropdown, filteredMentions, mentionSelectedIndex, insertMention, slashDropdown, filteredSlashCmds, slashSelectedIndex, insertSlashCommand, handleSend]); - - const files = pendingFiles ?? []; - - const mentionDropdownEl = mentionDropdown && filteredMentions.length > 0 && dropdownPos && createPortal( -
- {(() => { - let flatIdx = 0; - return groupedMentions.map(group => { - const icon = MENTION_TYPE_ICON[group.type]; - const label = t(`page.mentionType.${group.type}`); - return ( -
-
- {icon} - {label} -
- {group.items.map(a => { - const curIdx = flatIdx++; - return ( - - ); - })} -
- ); - }); - })()} -
, - document.body, - ); - - const slashDropdownEl = slashDropdown && filteredSlashCmds.length > 0 && dropdownPos && createPortal( -
- {(() => { - return filteredSlashCmds.map((cmd, i) => ( - - )); - })()} -
, - document.body, - ); - - return ( -
- {mentionDropdownEl} - {slashDropdownEl} - - {/* Context chips & mention chips */} - {((contextChips && contextChips.length > 0) || mentionChips.length > 0) && ( -
- {mentionChips.map(chip => ( - - {MENTION_TYPE_ICON[chip.entityType]} - {chip.name} - - - ))} - {contextChips?.map(chip => ( - - {chip.label} - {chip.onRemove && ( - - )} - - ))} -
- )} - - {/* Pending files */} - {files.length > 0 && ( -
- {files.map(img => ( -
- {isImageFile(img) ? ( - {img.name} - ) : ( -
- {getFileIcon(img.name, img.dataUrl)} - {img.name.split('.').pop()?.toUpperCase()} -
- )} - {onRemoveFile && ( - - )} -
- ))} - {files.length < maxFiles && onAttach && ( - - )} -
- )} - - {/* Vision warning */} - {visionWarning && ( -
- - {t('page.visionWarning')} -
- )} - - {/* Reply bar */} - {replyTo && ( -
-
- {replyTo.sender} -

{replyTo.text}

-
- {onClearReply && ( - - )} -
- )} - - {/* Input row */} -
- {onAttach && ( - - )} -