fix: align desktop chat with BoosAPI client flow

This commit is contained in:
boos
2026-06-29 12:27:27 +08:00
parent 2a5eca83c8
commit bdfb9b8dc2
7 changed files with 302 additions and 56 deletions
+10 -5
View File
@@ -30,7 +30,6 @@ import { useUserProfileStore } from '@/stores/userProfileStore';
import { useResolvedDarkMode } from '@/hooks/useResolvedDarkMode';
import { InputArea } from './InputArea';
import { ModelSelector } from './ModelSelector';
import { AgentWorkbenchComposer } from './AgentWorkbenchComposer';
import { parseSearchContent } from '@/lib/searchUtils';
import {
CHAT_CUSTOM_HTML_TAGS,
@@ -4141,8 +4140,16 @@ export function ChatView() {
</Typography.Text>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full" style={{ padding: '0 24px' }}>
<AgentWorkbenchComposer />
<div
className="flex flex-col items-center justify-center h-full"
style={{ gap: 8, padding: '0 24px', color: token.colorTextSecondary }}
>
<Typography.Title level={3} style={{ margin: 0, color: token.colorText }}>
{t('chat.welcome')}
</Typography.Title>
<Typography.Text type="secondary">
{t('chat.emptyPrompt', '输入消息开始对话,或在下方切换 Agent / 创作模式。')}
</Typography.Text>
</div>
)
) : (
@@ -4187,7 +4194,6 @@ export function ChatView() {
)}
{/* Input Area */}
{messages.length > 0 && (
<div className="relative">
{showScrollToBottom && (
<Button
@@ -4209,7 +4215,6 @@ export function ChatView() {
)}
<InputArea />
</div>
)}
<Modal
title={t('chat.compressionSummary')}
open={summaryModalOpen}
+236 -45
View File
@@ -1,9 +1,9 @@
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Button, Tooltip, App, theme, Dropdown, Tag, Popover, Checkbox, Badge, Popconfirm } from 'antd';
import type { MenuProps } from 'antd';
import { Paperclip, Trash2, Mic, Eraser, Scissors, Globe, Brain, Atom, Plug, SlidersHorizontal, ArrowUp, Square, Check, Zap, ZapOff, Shrink, Upload, GitCompareArrows, X, BookOpen, GripHorizontal, CircleOff, SignalLow, SignalMedium, SignalHigh, Signal, Bot, MessageSquare, Shield, ShieldCheck, ShieldAlert, FolderOpen, ExternalLink } from 'lucide-react';
import { Paperclip, Trash2, Mic, Eraser, Scissors, Globe, Brain, Atom, Plug, SlidersHorizontal, ArrowUp, Square, Check, Zap, ZapOff, Shrink, Upload, GitCompareArrows, X, BookOpen, GripHorizontal, CircleOff, SignalLow, SignalMedium, SignalHigh, Signal, MessageSquare, Shield, ShieldCheck, ShieldAlert, FolderOpen, ExternalLink, ImagePlus, Clapperboard, Music2, Mic2, UserRound, WandSparkles, Sparkles, Wrench } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useConversationStore, useProviderStore, useSettingsStore, useSearchStore, useMcpStore, useMemoryStore, useKnowledgeStore } from '@/stores';
import { useConversationStore, useProviderStore, useSettingsStore, useSearchStore, useMcpStore, useMemoryStore, useKnowledgeStore, useSkillStore } from '@/stores';
import { useUIStore } from '@/stores/uiStore';
import { findModelByIds, supportsReasoning, modelHasCapability } from '@/lib/modelCapabilities';
import {
@@ -41,6 +41,22 @@ const DOCUMENT_ATTACHMENT_MIME_BY_EXTENSION: Record<string, string> = {
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};
type ComposerCreationMode = 'chat' | 'agent' | 'image' | 'video' | 'music' | 'voice' | 'digital-human' | 'action';
const MEDIA_ATTACHMENT_ACCEPT = 'image/*,video/*,audio/*';
const ANY_ATTACHMENT_ACCEPT = `${MEDIA_ATTACHMENT_ACCEPT},${DOCUMENT_ATTACHMENT_ACCEPT}`;
const JIANYING_SKILL_NAME = 'jianying-editor';
const JIANYING_SKILL_SOURCE = 'luoluoluo22/jianying-editor-skill';
const JIANYING_PROMPT_PREFIX = '请使用 jianying-editor Skill 自动创建剪映草稿:';
const CREATIVE_PROMPT_PREFIX: Record<Exclude<ComposerCreationMode, 'chat' | 'agent'>, string> = {
image: '请使用 BoosAPI 的图片生成能力创作:',
video: '请使用 BoosAPI 的视频生成能力创作:',
music: '请为下面内容生成配乐方案:',
voice: '请使用配音/TTS 能力生成语音:',
'digital-human': '请使用数字人能力生成口播视频:',
action: '请使用动作模仿/动作迁移能力处理:',
};
function getAttachmentMimeType(fileName: string, mimeType?: string): string {
if (mimeType) return mimeType;
const extension = fileName.split('.').pop()?.toLowerCase() || '';
@@ -50,11 +66,15 @@ function getAttachmentMimeType(fileName: string, mimeType?: string): string {
function isAllowedAttachmentFile(
fileName: string,
mimeType: string | undefined,
hasVision: boolean,
_hasVision: boolean,
documentAttachmentReadingEnabled: boolean,
): boolean {
const effectiveMimeType = getAttachmentMimeType(fileName, mimeType);
if (hasVision && effectiveMimeType.startsWith('image/')) return true;
if (
effectiveMimeType.startsWith('image/')
|| effectiveMimeType.startsWith('video/')
|| effectiveMimeType.startsWith('audio/')
) return true;
if (!documentAttachmentReadingEnabled) return false;
return Object.values(DOCUMENT_ATTACHMENT_MIME_BY_EXTENSION).includes(effectiveMimeType);
}
@@ -90,6 +110,9 @@ export function InputArea() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<ComposerCreationMode>('chat');
const [skillsDropdownOpen, setSkillsDropdownOpen] = useState(false);
const [installingJianying, setInstallingJianying] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const valueRef = useRef(value);
@@ -143,6 +166,10 @@ export function InputArea() {
const conversations = useConversationStore((s) => s.conversations);
const providers = useProviderStore((s) => s.providers);
const settings = useSettingsStore((s) => s.settings);
const skills = useSkillStore((s) => s.skills);
const loadSkills = useSkillStore((s) => s.loadSkills);
const installSkill = useSkillStore((s) => s.installSkill);
const toggleSkill = useSkillStore((s) => s.toggleSkill);
const shortcutHint = useCallback((label: string, action: ShortcutAction) => {
if (!settings) return label;
@@ -226,6 +253,10 @@ export function InputArea() {
const activeConversation = conversations.find((c) => c.id === activeConversationId);
const currentMode = activeConversation?.mode || 'chat';
const selectedComposerMode: ComposerCreationMode = currentMode === 'agent' ? 'agent' : pendingMode;
const jianyingSkill = useMemo(() => (
skills.find((item) => item.name === JIANYING_SKILL_NAME) ?? null
), [skills]);
const setActivePage = useUIStore((s) => s.setActivePage);
const setSettingsSection = useUIStore((s) => s.setSettingsSection);
@@ -761,11 +792,63 @@ export function InputArea() {
hasVision: modelHasCapability(currentModel, 'Vision'),
}), [activeConversation, currentModel, providers]);
const documentAttachmentReadingEnabled = settings.document_attachment_reading_enabled ?? false;
const canAttachFiles = hasVision || documentAttachmentReadingEnabled;
const fileInputAccept = [
hasVision ? 'image/*' : null,
documentAttachmentReadingEnabled ? DOCUMENT_ATTACHMENT_ACCEPT : null,
].filter(Boolean).join(',');
const fileInputAccept = documentAttachmentReadingEnabled ? ANY_ATTACHMENT_ACCEPT : MEDIA_ATTACHMENT_ACCEPT;
const creationModeItems = useMemo<MenuProps['items']>(() => [
{
key: 'chat',
icon: <MessageSquare size={15} />,
label: (
<div>
<strong>{t('common.chatMode')}</strong>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>普通问答与多模型对话</div>
</div>
),
},
{
key: 'agent',
icon: <WandSparkles size={15} />,
label: (
<div>
<strong>{t('common.agentMode')} <Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', marginLeft: 4 }}>Beta</Tag></strong>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>拆解任务、读写文件、执行工具</div>
</div>
),
},
{
type: 'divider',
},
{
key: 'image',
icon: <ImagePlus size={15} />,
label: <div><strong>图片生成</strong><div style={{ fontSize: 12, color: token.colorTextSecondary }}>即梦图片、参考图与风格控制</div></div>,
},
{
key: 'video',
icon: <Clapperboard size={15} />,
label: <div><strong>视频生成</strong><div style={{ fontSize: 12, color: token.colorTextSecondary }}>即梦视频、图生视频与分镜</div></div>,
},
{
key: 'music',
icon: <Music2 size={15} />,
label: <div><strong>音乐生成</strong><div style={{ fontSize: 12, color: token.colorTextSecondary }}>配乐、氛围与短片 BGM</div></div>,
},
{
key: 'voice',
icon: <Mic2 size={15} />,
label: <div><strong>配音生成</strong><div style={{ fontSize: 12, color: token.colorTextSecondary }}>TTS 音色、语速与试听</div></div>,
},
{
key: 'digital-human',
icon: <UserRound size={15} />,
label: <div><strong>数字人</strong><div style={{ fontSize: 12, color: token.colorTextSecondary }}>口播、形象与视频工作流</div></div>,
},
{
key: 'action',
icon: <Sparkles size={15} />,
label: <div><strong>动作模仿</strong><div style={{ fontSize: 12, color: token.colorTextSecondary }}>动作迁移与主体参考</div></div>,
},
], [t, token.colorTextSecondary]);
// Current model key for excluding from multi-select (no longer used - users can select any model)
@@ -821,6 +904,7 @@ export function InputArea() {
);
const handleModeSwitch = useCallback(async (mode: 'chat' | 'agent') => {
setPendingMode(mode);
if (!activeConversation) return;
await updateConversation(activeConversation.id, { mode });
@@ -853,6 +937,50 @@ export function InputArea() {
}
}, [activeConversation, updateConversation, companionModels, companionStorageKey]);
const handleCreationModeSelect = useCallback(async (mode: ComposerCreationMode) => {
if (mode === 'chat' || mode === 'agent') {
await handleModeSwitch(mode);
return;
}
setPendingMode(mode);
if (activeConversation) {
await updateConversation(activeConversation.id, { mode: 'chat' });
}
setValue((current) => {
const prefix = CREATIVE_PROMPT_PREFIX[mode];
const text = current.trim();
if (!text || text.startsWith(prefix)) return current;
return `${prefix}${text}`;
});
}, [activeConversation, handleModeSwitch, updateConversation]);
const applyJianyingSkill = useCallback(async () => {
if (installingJianying) return;
setInstallingJianying(true);
try {
if (!jianyingSkill) {
await installSkill(JIANYING_SKILL_SOURCE);
await loadSkills();
messageApi.success('剪映 Skill 已安装');
} else if (!jianyingSkill.enabled) {
await toggleSkill(JIANYING_SKILL_NAME, true);
await loadSkills();
messageApi.success('剪映 Skill 已启用');
}
await handleModeSwitch('agent');
setValue((current) => {
const text = current.trim();
if (text.includes(JIANYING_SKILL_NAME) || text.includes('剪映')) return current;
return text ? `${JIANYING_PROMPT_PREFIX}${text}` : JIANYING_PROMPT_PREFIX;
});
setSkillsDropdownOpen(false);
} catch (error) {
messageApi.error(`剪映 Skill 集成失败:${String(error)}`);
} finally {
setInstallingJianying(false);
}
}, [handleModeSwitch, installSkill, installingJianying, jianyingSkill, loadSkills, messageApi, toggleSkill]);
const handleSend = useCallback(async () => {
const trimmed = value.trim();
if (!trimmed) return;
@@ -894,12 +1022,30 @@ export function InputArea() {
textareaRef.current.style.height = 'auto';
}
});
if (currentMode === 'agent') {
if (selectedComposerMode === 'agent') {
const nextConversation = useConversationStore.getState().conversations.find(
(conversation) => conversation.id === useConversationStore.getState().activeConversationId,
);
const nextConversationId = useConversationStore.getState().activeConversationId;
if (nextConversation && nextConversation.mode !== 'agent') {
await updateConversation(nextConversation.id, { mode: 'agent' });
}
if (nextConversationId) {
try {
await invoke('agent_update_session', { conversationId: nextConversationId });
} catch (e) {
console.warn('Failed to init agent session before send:', e);
}
}
await sendAgentMessage(trimmed, attachments);
} else if (companionModels.length > 0) {
await sendMultiModelMessage(trimmed, companionModels, attachments, searchEnabled ? searchProviderId : null);
const creativePrefix = selectedComposerMode !== 'chat' ? CREATIVE_PROMPT_PREFIX[selectedComposerMode] : '';
const content = creativePrefix && !trimmed.startsWith(creativePrefix) ? `${creativePrefix}${trimmed}` : trimmed;
await sendMultiModelMessage(content, companionModels, attachments, searchEnabled ? searchProviderId : null);
} else {
await sendMessage(trimmed, attachments, searchEnabled ? searchProviderId : null);
const creativePrefix = selectedComposerMode !== 'chat' ? CREATIVE_PROMPT_PREFIX[selectedComposerMode] : '';
const content = creativePrefix && !trimmed.startsWith(creativePrefix) ? `${creativePrefix}${trimmed}` : trimmed;
await sendMessage(content, attachments, searchEnabled ? searchProviderId : null);
}
} catch (e) {
setValue((current) => current || trimmed);
@@ -918,7 +1064,7 @@ export function InputArea() {
}
});
}
}, [value, attachedFiles, sendMessage, sendAgentMessage, sendMultiModelMessage, companionModels, activeConversationId, providers, settings, createConversation, messageApi, t, searchEnabled, searchProviderId, currentMode]);
}, [value, attachedFiles, sendMessage, sendAgentMessage, sendMultiModelMessage, companionModels, activeConversationId, providers, settings, createConversation, messageApi, t, searchEnabled, searchProviderId, selectedComposerMode, updateConversation]);
const handleFillLastMessage = useCallback(() => {
if (streaming) return;
@@ -969,7 +1115,6 @@ export function InputArea() {
}, []);
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!canAttachFiles) return;
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
@@ -993,14 +1138,12 @@ export function InputArea() {
e.preventDefault();
setAttachedFiles((prev) => [...prev, ...files]);
}
}, [canAttachFiles, documentAttachmentReadingEnabled, hasVision]);
}, [documentAttachmentReadingEnabled, hasVision]);
// Drag-and-drop overlay (Tauri native)
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!canAttachFiles) return;
let unlisten: (() => void) | undefined;
(async () => {
@@ -1025,6 +1168,8 @@ export function InputArea() {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
bmp: 'image/bmp', ico: 'image/x-icon',
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@@ -1061,7 +1206,7 @@ export function InputArea() {
return () => {
unlisten?.();
};
}, [canAttachFiles, documentAttachmentReadingEnabled, hasVision]);
}, [documentAttachmentReadingEnabled, hasVision]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -1404,17 +1549,15 @@ export function InputArea() {
</Tooltip>
</Dropdown>
)}
{canAttachFiles && (
<Tooltip title={t('chat.attachFile')}>
<Button
aria-label={t('chat.attachFile')}
type="text"
size="small"
icon={<Paperclip size={14} />}
onClick={handleFileSelect}
/>
</Tooltip>
)}
<Tooltip title={t('chat.attachFile')}>
<Button
aria-label={t('chat.attachFile')}
type="text"
size="small"
icon={<Paperclip size={14} />}
onClick={handleFileSelect}
/>
</Tooltip>
<Popover
trigger="click"
placement="topLeft"
@@ -1599,30 +1742,78 @@ export function InputArea() {
<div className="flex items-center gap-1">
<Dropdown
menu={{
items: [
{
key: 'chat',
icon: <MessageSquare size={14} />,
label: t('common.chatMode'),
},
{
key: 'agent',
icon: <Bot size={14} />,
label: <>{t('common.agentMode')} <Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', marginLeft: 2 }}>Beta</Tag></>,
},
],
selectedKeys: [currentMode],
onClick: ({ key }) => handleModeSwitch(key as 'chat' | 'agent'),
items: creationModeItems,
selectedKeys: [selectedComposerMode],
onClick: ({ key }) => void handleCreationModeSelect(key as ComposerCreationMode),
}}
trigger={['click']}
placement="topLeft"
>
<Button
type="text"
size="small"
icon={currentMode === 'agent' ? <Bot size={14} /> : <MessageSquare size={14} />}
icon={
selectedComposerMode === 'agent' ? <WandSparkles size={14} />
: selectedComposerMode === 'image' ? <ImagePlus size={14} />
: selectedComposerMode === 'video' ? <Clapperboard size={14} />
: selectedComposerMode === 'music' ? <Music2 size={14} />
: selectedComposerMode === 'voice' ? <Mic2 size={14} />
: selectedComposerMode === 'digital-human' ? <UserRound size={14} />
: selectedComposerMode === 'action' ? <Sparkles size={14} />
: <MessageSquare size={14} />
}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}
>
{currentMode === 'agent' ? <>{t('common.agentMode')} <Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', marginLeft: 2, marginRight: 0 }}>Beta</Tag></> : t('common.chatMode')}
{selectedComposerMode === 'agent' ? <>{t('common.agentMode')} <Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', marginLeft: 2, marginRight: 0 }}>Beta</Tag></>
: selectedComposerMode === 'image' ? '图片生成'
: selectedComposerMode === 'video' ? '视频生成'
: selectedComposerMode === 'music' ? '音乐生成'
: selectedComposerMode === 'voice' ? '配音生成'
: selectedComposerMode === 'digital-human' ? '数字人'
: selectedComposerMode === 'action' ? '动作模仿'
: t('common.chatMode')}
</Button>
</Dropdown>
<Dropdown
open={skillsDropdownOpen}
onOpenChange={(open) => {
setSkillsDropdownOpen(open);
if (open) void loadSkills();
}}
menu={{
items: [
{
key: 'jianying',
icon: <Wrench size={14} />,
label: (
<div>
<strong>剪映短片</strong>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
{jianyingSkill?.enabled ? '已启用,可直接用 Agent 创建剪映草稿' : '安装/启用 jianying-editor Skill'}
</div>
</div>
),
onClick: () => void applyJianyingSkill(),
},
{
key: 'skills-page',
icon: <BookOpen size={14} />,
label: '管理技能',
onClick: () => setActivePage('skills'),
},
],
}}
trigger={['click']}
placement="topLeft"
>
<Button
type="text"
size="small"
loading={installingJianying}
icon={<Wrench size={14} />}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}
>
使用技能
</Button>
</Dropdown>
{currentMode === 'agent' && (