chore: sync current workspace changes
Desktop CI Build / Build macOS ARM64 DMG (push) Has been cancelled
Desktop CI Build / Build Windows x64 (push) Has been cancelled

This commit is contained in:
boos
2026-07-14 03:21:10 +08:00
parent 1512fa23ce
commit 557914fea8
745 changed files with 124917 additions and 11222 deletions
@@ -1,526 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { App, Button, Dropdown, Input, Select, Switch, Tag, theme } from 'antd';
import type { MenuProps } from 'antd';
import {
ArrowUp,
AtSign,
ChevronDown,
Clapperboard,
ImagePlus,
Mic2,
Music2,
Plus,
SlidersHorizontal,
Sparkles,
UserRound,
WandSparkles,
Wrench,
X,
} from 'lucide-react';
import { invoke } from '@/lib/invoke';
import { findSkillByPreset, getSkillPresetStatus, SKILL_PRESETS, type SkillPreset } from '@/lib/skillPresets';
import { useConversationStore, useProviderStore, useSkillStore } from '@/stores';
import type { AttachmentInput, Model } from '@/types';
type CreationMode = 'agent' | 'image' | 'video' | 'music' | 'voice' | 'digital-human' | 'action';
interface CreationModeOption {
key: CreationMode;
label: string;
hint: string;
icon: React.ReactNode;
}
interface AttachmentItem {
id: string;
name: string;
type: string;
size: number;
data: string;
}
interface ChatModelOption {
value: string;
label: string;
providerId: string;
modelId: string;
}
const CREATION_MODES: CreationModeOption[] = [
{ key: 'agent', label: 'Agent 模式', hint: '拆解任务、读写文件、执行工具', icon: <WandSparkles size={16} /> },
{ key: 'image', label: '图片生成', hint: '即梦图片、参考图与风格控制', icon: <ImagePlus size={16} /> },
{ key: 'video', label: '视频生成', hint: '即梦视频、图生视频与分镜', icon: <Clapperboard size={16} /> },
{ key: 'music', label: '音乐生成', hint: '配乐、氛围与短片 BGM', icon: <Music2 size={16} /> },
{ key: 'voice', label: '配音生成', hint: 'TTS 音色、语速与试听', icon: <Mic2 size={16} /> },
{ key: 'digital-human', label: '数字人', hint: '口播、形象与视频工作流', icon: <UserRound size={16} /> },
{ key: 'action', label: '动作模仿', hint: '动作迁移与主体参考', icon: <Sparkles size={16} /> },
];
const JIMENG_IMAGE_MODELS = [
{ value: 'jimeng_t2i_v40', label: '即梦图片 4.0' },
{ value: 'custom', label: '自定义 req_key' },
];
const JIMENG_VIDEO_MODELS = [
{ value: 'jimeng_ti2v_v30_pro', label: '即梦图生视频 3.0 Pro' },
{ value: 'custom', label: '自定义 req_key' },
];
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function modelOptionLabel(model: Model, providerName: string): string {
return `${model.name || model.model_id} · ${providerName}`;
}
function isUsableChatModel(model: Model): boolean {
return model.enabled && (model.model_type === 'Chat' || model.capabilities.includes('TextChat'));
}
function getAttachmentMimeType(fileName: string, mimeType?: string): string {
if (mimeType) return mimeType;
const extension = fileName.split('.').pop()?.toLowerCase();
if (extension === 'pdf') return 'application/pdf';
if (extension === 'txt') return 'text/plain';
if (extension === 'md') return 'text/markdown';
if (extension === 'json') return 'application/json';
return 'application/octet-stream';
}
async function fileToAttachmentItem(file: File): Promise<AttachmentItem> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error(`读取文件失败:${file.name}`));
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1] || '';
resolve({
id: `${file.name}-${file.size}-${file.lastModified}-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
name: file.name,
type: getAttachmentMimeType(file.name, file.type),
size: file.size,
data: base64,
});
};
reader.readAsDataURL(file);
});
}
function toAttachmentInput(item: AttachmentItem): AttachmentInput {
return {
file_name: item.name,
file_type: item.type,
file_size: item.size,
data: item.data,
};
}
export function AgentWorkbenchComposer() {
const { token } = theme.useToken();
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const providers = useProviderStore((s) => s.providers);
const fetchProviders = useProviderStore((s) => s.fetchProviders);
const createConversation = useConversationStore((s) => s.createConversation);
const updateConversation = useConversationStore((s) => s.updateConversation);
const sendAgentMessage = useConversationStore((s) => s.sendAgentMessage);
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 [creationMode, setCreationMode] = useState<CreationMode>('agent');
const [autoMode, setAutoMode] = useState(true);
const [prompt, setPrompt] = useState('');
const [agentModel, setAgentModel] = useState('custom');
const [customAgentModel, setCustomAgentModel] = useState('');
const [imageModel, setImageModel] = useState('jimeng_t2i_v40');
const [videoModel, setVideoModel] = useState('jimeng_ti2v_v30_pro');
const [customJimengModel, setCustomJimengModel] = useState('');
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [subjectOpen, setSubjectOpen] = useState(false);
const [skillOpen, setSkillOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [installingSkillKey, setInstallingSkillKey] = useState<string | null>(null);
const [result, setResult] = useState('');
useEffect(() => {
if (providers.length === 0) {
void fetchProviders().catch((error) => message.error(String(error)));
}
}, [fetchProviders, message, providers.length]);
useEffect(() => {
void loadSkills();
}, [loadSkills]);
const chatModelOptions = useMemo<ChatModelOption[]>(() => {
const rows = providers
.filter((provider) => provider.enabled)
.flatMap((provider) =>
provider.models
.filter(isUsableChatModel)
.map((model) => ({
value: `${provider.id}::${model.model_id}`,
label: modelOptionLabel(model, provider.name),
providerId: provider.id,
modelId: model.model_id,
})),
);
const seen = new Set<string>();
return rows.filter((item) => {
if (seen.has(item.value)) return false;
seen.add(item.value);
return true;
});
}, [providers]);
useEffect(() => {
if (agentModel !== 'custom') return;
if (customAgentModel || chatModelOptions.length === 0) return;
setAgentModel(chatModelOptions[0].value);
}, [agentModel, chatModelOptions, customAgentModel]);
const currentMode = CREATION_MODES.find((item) => item.key === creationMode) ?? CREATION_MODES[0];
const selectedModel = creationMode === 'agent'
? (agentModel === 'custom' ? customAgentModel.trim() || '自定义模型' : agentModel)
: creationMode === 'image'
? (imageModel === 'custom' ? customJimengModel.trim() || '自定义 req_key' : imageModel)
: creationMode === 'video'
? (videoModel === 'custom' ? customJimengModel.trim() || '自定义 req_key' : videoModel)
: '待配置';
const modeItems: MenuProps['items'] = CREATION_MODES.map((item) => ({
key: item.key,
label: (
<div className="agent-workbench-mode-item">
<span>{item.icon}</span>
<div>
<strong>{item.label}</strong>
<small>{item.hint}</small>
</div>
{item.key === creationMode && <em>✓</em>}
</div>
),
}));
const selectedChatModel = useMemo(() => (
chatModelOptions.find((item) => item.value === agentModel) ?? null
), [agentModel, chatModelOptions]);
const skillMenuPresets = useMemo(() => SKILL_PRESETS.filter((preset) => (
preset.category === 'video' || preset.category === 'document' || preset.category === 'media'
)), []);
const handleFilesSelected = useCallback((files: FileList | null) => {
if (!files || files.length === 0) return;
void Promise.all(Array.from(files).map(fileToAttachmentItem))
.then((next) => {
setAttachments((current) => [...current, ...next]);
})
.catch((error) => message.error(String(error)))
.finally(() => {
if (fileInputRef.current) fileInputRef.current.value = '';
});
}, [message]);
const removeAttachment = useCallback((id: string) => {
setAttachments((current) => current.filter((item) => item.id !== id));
}, []);
const applySkillPreset = useCallback(async (preset: SkillPreset) => {
if (installingSkillKey) return;
setInstallingSkillKey(preset.key);
try {
const existingSkill = findSkillByPreset(useSkillStore.getState().skills, preset);
if (!existingSkill) {
await installSkill(preset.source);
await loadSkills();
message.success(`${preset.title} Skill 已安装`);
} else if (!existingSkill.enabled) {
await toggleSkill(preset.name, true);
await loadSkills();
message.success(`${preset.title} Skill 已启用`);
}
setCreationMode('agent');
setPrompt((value) => {
const text = value.trim();
if (text.includes(preset.name) || text.includes(preset.title)) return value;
return text ? `${preset.promptPrefix}${text}` : preset.promptPrefix;
});
setSkillOpen(false);
} catch (error) {
message.error(`${preset.title} Skill 集成失败:${String(error)}`);
} finally {
setInstallingSkillKey(null);
}
}, [installSkill, installingSkillKey, loadSkills, message, toggleSkill]);
const submit = useCallback(async () => {
const text = prompt.trim();
if (!text) {
message.warning('先输入想法、剧本或任务说明');
return;
}
if (submitting) return;
if (creationMode !== 'agent') {
setResult(`${currentMode.label} 已准备好。模型:${selectedModel};参考素材:${attachments.length} 个。即梦/媒体生成链路下一步接入。`);
return;
}
const fallbackProvider = providers.find((provider) => provider.enabled);
const providerId = selectedChatModel?.providerId ?? fallbackProvider?.id;
const modelId = selectedChatModel?.modelId ?? customAgentModel.trim();
if (!providerId || !modelId) {
message.warning('先在供应商里启用一个对话模型,或填写自定义模型名');
return;
}
setSubmitting(true);
try {
const title = text.slice(0, 24) || 'Agent 创作';
const conversation = await createConversation(title, modelId, providerId);
await updateConversation(conversation.id, { mode: 'agent', provider_id: providerId, model_id: modelId });
await invoke('agent_update_session', {
conversationId: conversation.id,
permissionMode: autoMode ? 'accept_edits' : 'default',
});
await sendAgentMessage(text, attachments.map(toAttachmentInput));
setResult(`Agent 已开始执行。模型:${modelId};参考素材:${attachments.length} 个。`);
setPrompt('');
setAttachments([]);
} catch (error) {
message.error(String(error));
setResult(`Agent 启动失败:${String(error)}`);
} finally {
setSubmitting(false);
}
}, [
attachments,
autoMode,
createConversation,
creationMode,
currentMode.label,
customAgentModel,
message,
prompt,
providers,
selectedChatModel,
selectedModel,
sendAgentMessage,
submitting,
updateConversation,
]);
return (
<div className="agent-workbench-desktop" style={{ background: token.colorBgContainer }}>
<div className="agent-workbench-desktop-bg" />
<section className="agent-workbench-desktop-hero">
<h1>你好,想创作什么?</h1>
<div className="agent-workbench-desktop-composer">
<input
ref={fileInputRef}
className="agent-workbench-file-input"
type="file"
multiple
accept="image/*,video/*,audio/*,.pdf,.txt,.md,.json"
onChange={(event) => handleFilesSelected(event.target.files)}
/>
<button
className="agent-workbench-upload"
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="上传参考素材"
>
<Plus size={24} />
</button>
<Input.TextArea
className="agent-workbench-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
onPressEnter={(event) => {
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
void submit();
}
}}
placeholder="输入想法、剧本或上传参考,支持 “/” 使用技能,@ 添加主体,和 Agent 一起创作"
autoSize={{ minRows: 4, maxRows: 8 }}
/>
{attachments.length > 0 && (
<div className="agent-workbench-attachments">
{attachments.map((item) => (
<Tag
key={item.id}
closeIcon={<X size={12} />}
onClose={(event) => {
event.preventDefault();
removeAttachment(item.id);
}}
>
{item.name} <span>{formatFileSize(item.size)}</span>
</Tag>
))}
</div>
)}
<div className="agent-workbench-toolbar">
<div className="agent-workbench-tool-left">
<Dropdown
trigger={['click']}
menu={{
items: modeItems,
selectable: true,
selectedKeys: [creationMode],
onClick: ({ key }) => setCreationMode(key as CreationMode),
}}
overlayClassName="agent-workbench-mode-menu"
>
<Button className="agent-workbench-pill active" icon={currentMode.icon}>
{currentMode.label} <ChevronDown size={14} />
</Button>
</Dropdown>
<Button className="agent-workbench-pill" icon={<SlidersHorizontal size={15} />} onClick={() => setAutoMode((value) => !value)}>
自动
<Switch size="small" checked={autoMode} onChange={setAutoMode} onClick={(_, event) => event.stopPropagation()} />
</Button>
{creationMode === 'agent' && (
<>
<Select
className="agent-workbench-model-select"
value={agentModel}
onChange={(value) => {
setAgentModel(value);
if (value !== 'custom') setCustomAgentModel('');
}}
options={[
...chatModelOptions,
{ value: 'custom', label: '自定义模型' },
]}
popupMatchSelectWidth={false}
/>
{agentModel === 'custom' && (
<Input
className="agent-workbench-model-input"
value={customAgentModel}
onChange={(event) => setCustomAgentModel(event.target.value)}
placeholder="输入 BoosAPI 模型名"
/>
)}
</>
)}
{creationMode === 'image' && (
<>
<Select
className="agent-workbench-model-select"
value={imageModel}
onChange={setImageModel}
options={JIMENG_IMAGE_MODELS}
popupMatchSelectWidth={false}
/>
{imageModel === 'custom' && (
<Input
className="agent-workbench-model-input"
value={customJimengModel}
onChange={(event) => setCustomJimengModel(event.target.value)}
placeholder="输入火山 req_key"
/>
)}
</>
)}
{creationMode === 'video' && (
<>
<Select
className="agent-workbench-model-select"
value={videoModel}
onChange={setVideoModel}
options={JIMENG_VIDEO_MODELS}
popupMatchSelectWidth={false}
/>
{videoModel === 'custom' && (
<Input
className="agent-workbench-model-input"
value={customJimengModel}
onChange={(event) => setCustomJimengModel(event.target.value)}
placeholder="输入火山 req_key"
/>
)}
</>
)}
<Dropdown
open={skillOpen}
onOpenChange={setSkillOpen}
trigger={['click']}
dropdownRender={() => (
<div className="agent-workbench-popover">
<Input placeholder="搜索技能" prefix={<Wrench size={15} />} />
{skillMenuPresets.map((preset) => {
const status = getSkillPresetStatus(skills, preset);
return (
<button key={preset.key} type="button" onClick={() => void applySkillPreset(preset)}>
{preset.key === 'jianying' ? <Clapperboard size={16} /> : <Wrench size={16} />}
{preset.title}
<small>
{installingSkillKey === preset.key
? '正在安装/启用...'
: status === 'enabled'
? `已启用,${preset.description}`
: status === 'installed'
? '已安装,点击启用并写入提示'
: `安装/启用 ${preset.name} Skill`}
</small>
</button>
);
})}
<button type="button"><Plus size={16} /> 用 Agent 创建技能</button>
</div>
)}
>
<Button className="agent-workbench-pill" loading={Boolean(installingSkillKey)} icon={<Wrench size={15} />}>使用技能</Button>
</Dropdown>
<Dropdown
open={subjectOpen}
onOpenChange={setSubjectOpen}
trigger={['click']}
dropdownRender={() => (
<div className="agent-workbench-subject-popover">
<span>可能@的内容</span>
<div>
<AtSign size={36} />
<strong>你还没有创建过主体</strong>
<button
type="button"
onClick={() => {
setPrompt((value) => `${value}${value && !value.endsWith(' ') ? ' ' : ''}@主体 `);
setSubjectOpen(false);
}}
>
+ 创建主体
</button>
</div>
</div>
)}
>
<Button className="agent-workbench-pill square" icon={<AtSign size={16} />} />
</Dropdown>
</div>
<Button
className="agent-workbench-submit"
type="primary"
icon={<ArrowUp size={18} />}
loading={submitting}
onClick={() => void submit()}
/>
</div>
</div>
{result && (
<div className="agent-workbench-result">
<strong>{currentMode.label}</strong>
<p>{result}</p>
</div>
)}
</section>
</div>
);
}
+8 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { Card, Input, Button, Space, Typography, Checkbox, theme } from 'antd';
import { Alert, Card, Input, Button, Space, Typography, Checkbox, theme } from 'antd';
import { MessageCircleQuestion, CheckCircle2 } from 'lucide-react';
import { useAgentStore } from '@/stores';
import { useTranslation } from 'react-i18next';
@@ -21,6 +21,7 @@ const AskUserCard: React.FC<AskUserCardProps> = ({ askId, question, options }) =
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const respondAskUser = useAgentStore((s) => s.respondAskUser);
const hasOptions = options && options.length > 0;
@@ -51,10 +52,13 @@ const AskUserCard: React.FC<AskUserCardProps> = ({ askId, question, options }) =
const finalAnswer = buildAnswer();
if (!finalAnswer || submitting || submitted) return;
setSubmitting(true);
setError(null);
try {
await respondAskUser(askId, finalAnswer);
setSubmitted(true);
} catch {
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : String(submitError));
} finally {
setSubmitting(false);
}
};
@@ -114,6 +118,7 @@ const AskUserCard: React.FC<AskUserCardProps> = ({ askId, question, options }) =
}
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={submitting || submitted}
autoFocus
style={{ borderColor: token.colorPrimary }}
onPressEnter={(e) => {
if (!e.shiftKey) {
@@ -122,6 +127,7 @@ const AskUserCard: React.FC<AskUserCardProps> = ({ askId, question, options }) =
}
}}
/>
{error && <Alert type="error" showIcon message={error} aria-live="polite" />}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{submitted ? (
<Space size={4} style={{ color: token.colorSuccess }}>
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -1,5 +1,5 @@
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { Tag, Modal, Input, theme, Tooltip, Button, Checkbox } from 'antd';
import { Tag, Modal, Input, theme, Tooltip, Button, Checkbox, Empty } from 'antd';
import { Search, Settings, Pin, PinOff, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Eye, Wrench, Lightbulb, Mic, MessageSquare, Check, GitCompareArrows } from 'lucide-react';
import { ModelIcon } from '@lobehub/icons';
import { useTranslation } from 'react-i18next';
@@ -250,6 +250,7 @@ export function ModelSelector({ style, onSelect, overrideCurrentModel, children,
}, [open, providerIds]);
const allGroupsExpanded = expandedGroups.length >= providerIds.length;
const hasEnabledProviders = providers.some((provider) => provider.enabled);
const toggleGroupExpand = useCallback((providerId: string) => {
setExpandedGroups((prev) =>
@@ -527,6 +528,30 @@ export function ModelSelector({ style, onSelect, overrideCurrentModel, children,
data-os-scrollbar
style={{ overflowY: 'auto', flex: 1, padding: '2px 0 4px' }}
>
{flatRows.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
hasEnabledProviders
? '当前服务商还没有可用模型'
: '还没有启用的 BoosAPI 服务商'
}
style={{ padding: '32px 12px' }}
>
<Button
size="small"
type="primary"
onClick={() => {
setOpen(false);
setSearch('');
setActivePage('settings');
setSettingsSection('providers');
}}
>
{hasEnabledProviders ? '同步模型' : '导入客户端'}
</Button>
</Empty>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = flatRows[virtualRow.index];
@@ -640,6 +665,7 @@ export function ModelSelector({ style, onSelect, overrideCurrentModel, children,
);
})}
</div>
)}
</div>
</Modal>
</>
+16 -9
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Button, Card, Space, Tag, Typography, theme } from 'antd';
import { Alert, Button, Card, Space, Tag, Typography, theme } from 'antd';
import { Shield, ShieldCheck, ShieldX, ChevronDown, ChevronRight } from 'lucide-react';
import { useAgentStore } from '@/stores';
import { useTranslation } from 'react-i18next';
@@ -26,13 +26,16 @@ const PermissionCard: React.FC<PermissionCardProps> = ({
const [expanded, setExpanded] = useState(false);
const approveToolUse = useAgentStore((state) => state.approveToolUse);
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleApprove = async (decision: string) => {
setLoading(decision);
setError(null);
try {
await approveToolUse(conversationId, toolUseId, decision);
} catch (e) {
console.error('[PermissionCard] handleApprove failed:', e);
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(null);
}
@@ -95,6 +98,8 @@ const PermissionCard: React.FC<PermissionCardProps> = ({
</pre>
)}
{error && <Alert type="error" showIcon message={error} aria-live="polite" />}
{/* Action buttons or result */}
{status === 'pending' ? (
<Space>
@@ -107,14 +112,16 @@ const PermissionCard: React.FC<PermissionCardProps> = ({
>
{t('common.allowOnce', 'Allow Once')}
</Button>
<Button
size="small"
icon={<ShieldCheck size={14} />}
loading={loading === 'allow_always'}
onClick={() => handleApprove('allow_always')}
>
{t('common.allowAlways', 'Always Allow')}
</Button>
{toolName.toLowerCase() !== 'bash' && (
<Button
size="small"
icon={<ShieldCheck size={14} />}
loading={loading === 'allow_always'}
onClick={() => handleApprove('allow_always')}
>
{t('common.allowAlways', 'Always Allow')}
</Button>
)}
<Button
size="small"
danger
@@ -22,6 +22,9 @@ const insertContextClear = vi.fn();
const getContextUsage = vi.fn();
const setActivePage = vi.fn();
const setSettingsSection = vi.fn();
const loadSkills = vi.fn();
const installSkill = vi.fn();
const toggleSkill = vi.fn();
const conversationState = {
streaming: false,
@@ -36,6 +39,7 @@ const conversationState = {
title: 'Test',
provider_id: 'provider-1',
model_id: 'model-1',
mode: 'chat' as 'chat' | 'agent',
},
],
searchEnabled: true,
@@ -112,6 +116,13 @@ const memoryState = {
loadNamespaces: loadMemoryNamespaces,
};
const skillState = {
skills: [],
loadSkills,
installSkill,
toggleSkill,
};
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
@@ -132,6 +143,10 @@ vi.mock('@/stores', () => ({
useMcpStore: (selector: (state: typeof mcpState) => unknown) => selector(mcpState),
useKnowledgeStore: (selector: (state: typeof knowledgeState) => unknown) => selector(knowledgeState),
useMemoryStore: (selector: (state: typeof memoryState) => unknown) => selector(memoryState),
useSkillStore: Object.assign(
(selector: (state: typeof skillState) => unknown) => selector(skillState),
{ getState: () => skillState },
),
}));
vi.mock('@/stores/uiStore', () => ({
@@ -179,6 +194,7 @@ describe('InputArea', () => {
providerState.providers[0].models[0].capabilities = [];
providerState.providers[0].models[0].param_overrides = null;
conversationState.conversations[0].model_id = 'model-1';
conversationState.conversations[0].mode = 'chat';
conversationState.thinkingBudget = null;
conversationState.thinkingLevel = null;
conversationState.compressingConversationId = null;
@@ -339,4 +355,24 @@ describe('InputArea', () => {
expect(input?.accept).toContain('.doc');
expect(input?.accept).toContain('.docx');
});
it('separates request tools from Agent session controls and groups resource menus', async () => {
conversationState.conversations[0].mode = 'agent';
render(
<App>
<InputArea />
</App>,
);
expect(screen.getByLabelText('消息工具栏')).toBeInTheDocument();
expect(screen.getByLabelText('Agent 会话设置')).toBeInTheDocument();
expect(screen.getByLabelText('上下文与会话')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('资源与上下文'));
expect(await screen.findByText('MCP 工具')).toBeInTheDocument();
expect(screen.getByText('知识库')).toBeInTheDocument();
expect(screen.getByText('记忆')).toBeInTheDocument();
});
});
+11 -5
View File
@@ -3,7 +3,7 @@ import { Select, theme } from 'antd';
import { ModelIcon } from '@lobehub/icons';
import { useProviderStore } from '@/stores';
import { SmartProviderIcon } from '@/lib/providerIcons';
import type { ModelType } from '@/types';
import type { Model, ModelType } from '@/types';
/** Parse a combined `providerId::modelId` value. */
export function parseModelValue(value: string | undefined) {
@@ -14,13 +14,17 @@ export function parseModelValue(value: string | undefined) {
}
/** Hook: returns grouped Select options (Provider → Models) */
export function useGroupedModelOptions(modelType?: ModelType) {
export function useGroupedModelOptions(modelType?: ModelType, filterModel?: (model: Model) => boolean) {
const providers = useProviderStore((s) => s.providers);
return useMemo(() => {
return providers
.filter((p) => p.enabled)
.map((p) => {
const models = p.models.filter((m) => m.enabled && (!modelType || m.model_type === modelType));
const models = p.models.filter((m) => (
m.enabled
&& (!modelType || m.model_type === modelType)
&& (!filterModel || filterModel(m))
));
if (models.length === 0) return null;
return {
label: (
@@ -39,7 +43,7 @@ export function useGroupedModelOptions(modelType?: ModelType) {
};
})
.filter((option): option is NonNullable<typeof option> => option !== null);
}, [providers, modelType]);
}, [providers, modelType, filterModel]);
}
/** Hook: returns Map<providerId, providerName> */
@@ -63,6 +67,7 @@ export function ModelSelect({
allowClear = true,
style,
modelType,
filterModel,
}: {
value?: string;
onChange: (value: string | undefined) => void;
@@ -70,9 +75,10 @@ export function ModelSelect({
allowClear?: boolean;
style?: React.CSSProperties;
modelType?: ModelType;
filterModel?: (model: Model) => boolean;
}) {
const { token } = theme.useToken();
const groupedOptions = useGroupedModelOptions(modelType);
const groupedOptions = useGroupedModelOptions(modelType, filterModel);
const providerNameMap = useProviderNameMap();
const optionRender = useCallback(