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(
+1 -1
View File
@@ -66,7 +66,7 @@ export function confirmProviderDeepLinkImport(
deps.setSettingsSection('providers');
deps.modal.confirm({
title: deps.t('settings.deepLinkProviderConfirmTitle', '导入服务商配置'),
title: deps.t('settings.deepLinkProviderConfirmTitle', '导入客户端'),
content: <ProviderDeepLinkConfirmContent payload={payload} />,
okText: deps.t('common.confirm', '确认'),
cancelText: deps.t('common.cancel', '取消'),
+1 -1
View File
@@ -565,7 +565,7 @@
"documentation": "Documentation",
"openSource": "Open Source Licenses",
"addProvider": "Add Provider",
"deepLinkProviderConfirmTitle": "Import Provider Configuration",
"deepLinkProviderConfirmTitle": "Import to Desktop",
"deepLinkProviderCreated": "Provider imported",
"deepLinkProviderKeyAdded": "Provider already exists; key added",
"deepLinkProviderReusedKey": "Provider and key already exist",
+1 -1
View File
@@ -565,7 +565,7 @@
"documentation": "文档",
"openSource": "开源协议",
"addProvider": "添加服务商",
"deepLinkProviderConfirmTitle": "导入服务商配置",
"deepLinkProviderConfirmTitle": "导入客户端",
"deepLinkProviderCreated": "服务商已导入",
"deepLinkProviderKeyAdded": "服务商已存在,密钥已添加",
"deepLinkProviderReusedKey": "服务商与密钥已存在",
+145 -1
View File
@@ -144,6 +144,17 @@ html[data-theme="dark"] .agent-workbench-desktop-bg {
color: var(--color-text-secondary);
}
.agent-workbench-attachments .ant-tag em {
color: var(--color-primary);
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.agent-workbench-attachments .ant-tag em.error {
color: #ff4d4f;
}
.agent-workbench-toolbar {
display: flex;
justify-content: space-between;
@@ -229,6 +240,110 @@ html[data-theme="dark"] .agent-workbench-desktop-bg {
line-height: 1.6;
}
.agent-workbench-jimeng-panel {
width: min(78vw, 920px);
padding: 18px;
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--border-color));
border-radius: 18px;
background: color-mix(in srgb, var(--color-bg-elevated) 96%, transparent);
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.07);
}
.agent-workbench-jimeng-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px 140px;
gap: 12px;
}
.agent-workbench-jimeng-fields label {
display: grid;
gap: 6px;
min-width: 0;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 700;
}
.agent-workbench-jimeng-fields .ant-input,
.agent-workbench-jimeng-fields .ant-select-selector {
min-height: 42px;
border-radius: 12px !important;
}
.agent-workbench-jimeng-upload-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 650;
}
.agent-workbench-jimeng-upload-row .ant-btn {
min-height: 38px;
border-radius: 12px;
font-weight: 760;
}
.agent-workbench-jimeng-hint {
margin: 12px 0 0;
color: var(--color-text-secondary);
line-height: 1.6;
}
.agent-workbench-jimeng-jobs {
display: grid;
gap: 10px;
margin-top: 14px;
}
.agent-workbench-jimeng-job {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 14px;
background: color-mix(in srgb, var(--color-fill-alter) 55%, var(--color-bg-elevated));
}
.agent-workbench-jimeng-job > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.agent-workbench-jimeng-job strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-workbench-jimeng-error {
margin: 0;
color: #ff4d4f;
line-height: 1.55;
}
.agent-workbench-jimeng-assets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.agent-workbench-jimeng-assets a {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-elevated));
color: var(--color-primary);
font-weight: 700;
}
.agent-workbench-mode-menu .ant-dropdown-menu {
width: 300px;
padding: 8px;
@@ -346,7 +461,8 @@ html[data-theme="dark"] .agent-workbench-desktop-bg {
@media (max-width: 980px) {
.agent-workbench-desktop-composer,
.agent-workbench-result {
.agent-workbench-result,
.agent-workbench-jimeng-panel {
width: min(92vw, 980px);
}
@@ -383,6 +499,15 @@ html[data-theme="dark"] .agent-workbench-desktop-bg {
.agent-workbench-submit {
width: 100%;
}
.agent-workbench-jimeng-fields {
grid-template-columns: 1fr;
}
.agent-workbench-jimeng-upload-row {
align-items: stretch;
flex-direction: column;
}
}
/* Remove antd focus outline (green border) globally */
@@ -868,6 +993,21 @@ svg.lucide {
margin-top: 14px;
}
.documents-compose-meta {
display: flex;
align-items: center;
flex: 1 1 auto;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.documents-model-label {
color: var(--ant-color-text-secondary);
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 920px) {
.documents-page {
padding: 20px;
@@ -882,6 +1022,10 @@ svg.lucide {
.documents-layout {
grid-template-columns: 1fr;
}
.documents-compose-meta {
width: 100%;
}
}
/* ModelSelector: compact modal content padding */
@@ -38,13 +38,54 @@ describe('parseProviderDeepLink', () => {
}
});
it('parses website import-client aliases', () => {
const payload = parseProviderDeepLink(
'boosapi://import-provider?api_url=https%3A%2F%2Fapi.boos.lat%2Fv1&api_key=sk-boos&type=openai',
);
expect(payload).toEqual({
name: 'BoosAPI',
baseurl: 'https://api.boos.lat/v1',
apikey: 'sk-boos',
type: 'openai',
});
});
it('parses website import-client provider aliases', () => {
const payload = parseProviderDeepLink(
'boosapi:///import-provider?provider_name=BoosAPI&base_url=https%3A%2F%2Fapi.boos.lat%2Fv1&key=sk-boos&provider_type=custom',
);
expect(payload).toEqual({
name: 'BoosAPI',
baseurl: 'https://api.boos.lat/v1',
apikey: 'sk-boos',
type: 'custom',
});
});
it('ignores links that are not provider imports', () => {
expect(parseProviderDeepLink('boosapi://chat?name=Example')).toBeNull();
expect(parseProviderDeepLink('https://example.com')).toBeNull();
});
it('ignores links missing required provider parameters', () => {
expect(parseProviderDeepLink('boosapi://providers?name=Example&baseurl=https%3A%2F%2Fapi.example.com&apikey=sk')).toBeNull();
it('uses OpenAI-compatible defaults when optional fields are omitted', () => {
expect(parseProviderDeepLink('boosapi://providers?baseurl=https%3A%2F%2Fapi.example.com&apikey=sk')).toEqual({
name: 'BoosAPI',
baseurl: 'https://api.example.com',
apikey: 'sk',
type: 'openai',
});
expect(parseProviderDeepLink('boosapi://providers?name=Example&baseurl=https%3A%2F%2Fapi.example.com&apikey=sk')).toEqual({
name: 'Example',
baseurl: 'https://api.example.com',
apikey: 'sk',
type: 'openai',
});
});
it('ignores links missing required provider URL or API key', () => {
expect(parseProviderDeepLink('boosapi://providers?name=Example&apikey=sk&type=openai')).toBeNull();
expect(parseProviderDeepLink('boosapi://providers?name=Example&baseurl=https%3A%2F%2Fapi.example.com&type=openai')).toBeNull();
});
});
+186 -6
View File
@@ -119,6 +119,147 @@ function mergeBuiltInSkills(skills: any[]) {
return [...missing, ...skills];
}
interface MockJimengJob {
id: string;
type: 'image' | 'video';
title: string;
status: string;
message: string | null;
error: string | null;
progress: number;
progressTotal: number;
result: { assets?: Array<{ publicId: string; status: string; fileUrl: string }> } | null;
createdAt: string;
updatedAt: string;
startedAt: string | null;
finishedAt: string | null;
pollCount: number;
}
function getJimengJobs(): MockJimengJob[] {
return getStore<MockJimengJob[]>('jimeng_jobs', []);
}
function setJimengJobs(jobs: MockJimengJob[]) {
setStore('jimeng_jobs', jobs);
}
function createMockJimengJob(type: 'image' | 'video', body: any): MockJimengJob {
const now = new Date().toISOString();
const id = `${type === 'image' ? 'jmi' : 'jmv'}_${genId().slice(0, 8)}`;
return {
id,
type,
title: body?.prompt?.slice(0, 28) || (type === 'image' ? '即梦图片任务' : '即梦视频任务'),
status: 'running',
message: '浏览器预览模式任务',
error: null,
progress: 1,
progressTotal: 4,
result: null,
createdAt: now,
updatedAt: now,
startedAt: now,
finishedAt: null,
pollCount: 0,
};
}
function advanceMockJimengJob(job: MockJimengJob): MockJimengJob {
if (['succeeded', 'failed', 'cancelled'].includes(job.status)) return job;
const pollCount = job.pollCount + 1;
const done = pollCount >= 2;
const now = new Date().toISOString();
return {
...job,
pollCount,
progress: done ? 4 : Math.min(3, job.progress + 1),
status: done ? 'succeeded' : 'running',
updatedAt: now,
finishedAt: done ? now : null,
result: done
? {
assets: [
{
publicId: `${job.id}_asset`,
status: 'ready',
fileUrl: job.type === 'image'
? 'https://placehold.co/1080x1920/png?text=Jimeng+Preview'
: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
},
],
}
: job.result,
};
}
function installJimengFetchMock() {
if (typeof window === 'undefined') return;
const marker = '__BOOSAPI_JIMENG_FETCH_MOCK__';
if ((window as any)[marker]) return;
(window as any)[marker] = true;
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const parsed = new URL(url, window.location.origin);
const ok = (payload: unknown) => new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
if (parsed.pathname === '/api/digital-human/upload') {
const publicId = `wa_mock_${Date.now()}`;
return ok({
success: true,
asset: {
publicId,
kind: 'image',
status: 'completed',
fileUrl: `/api/user/workspace/assets/${publicId}/file`,
workspaceFileUrl: `/api/user/workspace/assets/${publicId}/file`,
},
});
}
if (!/\/api\/jimeng\//.test(url)) {
return originalFetch(input, init);
}
if (parsed.pathname === '/api/jimeng/status') {
return ok({
success: true,
enabled: true,
role: 'browser-preview',
userId: null,
keyName: 'browser-preview',
capabilities: {
image: { reqKey: 'jimeng_t2i_v40' },
video: { reqKey: 'jimeng_ti2v_v30_pro' },
},
});
}
if (parsed.pathname === '/api/jimeng/jobs' && (!init?.method || init.method === 'GET')) {
return ok({ success: true, jobs: getJimengJobs().map(advanceMockJimengJob) });
}
if (parsed.pathname === '/api/jimeng/image/jobs' || parsed.pathname === '/api/jimeng/video/jobs') {
const body = init?.body ? JSON.parse(String(init.body)) : {};
const job = createMockJimengJob(parsed.pathname.includes('/image/') ? 'image' : 'video', body);
setJimengJobs([job, ...getJimengJobs()]);
return ok({ success: true, job });
}
const match = parsed.pathname.match(/\/api\/jimeng\/jobs\/([^/]+)/);
if (match) {
const id = decodeURIComponent(match[1]);
const jobs = getJimengJobs();
const job = jobs.find((item) => item.id === id);
if (!job) return new Response(JSON.stringify({ success: false, error: 'Job not found' }), { status: 404 });
const next = advanceMockJimengJob(job);
setJimengJobs(jobs.map((item) => (item.id === id ? next : item)));
return ok({ success: true, job: next });
}
return new Response(JSON.stringify({ success: false, error: 'Unsupported mock Jimeng route' }), { status: 404 });
};
}
installJimengFetchMock();
// ── Built-in Providers ──────────────────────────────────────────────────
const OPENAI_REASONING_OVERRIDES = {
@@ -182,6 +323,36 @@ function rerankModel(providerId: string, modelId: string, name: string, enabled
}
const BUILT_IN_PROVIDERS = [
{
id: 'builtin-boosapi',
name: 'BoosAPI',
provider_type: 'custom',
api_host: 'https://api.boos.lat',
api_path: '/v1/chat/completions',
enabled: true,
models: [
chatModel('builtin-boosapi', 'gpt-5.5', 'GPT-5.5', ['TextChat', 'Vision', 'FunctionCalling', 'Reasoning'], 1000000),
chatModel('builtin-boosapi', 'jimeng_t2i_v40', '即梦图片 4.0', ['TextChat'], null, false),
chatModel('builtin-boosapi', 'jimeng_ti2v_v30_pro', '即梦图生视频 3.0 Pro', ['TextChat'], null, false),
],
keys: [
{
id: 'builtin-boosapi-key',
provider_id: 'builtin-boosapi',
key_encrypted: 'sk-boos-browser-preview',
key_prefix: 'sk-boos...',
enabled: true,
last_validated_at: null,
last_error: null,
rotation_index: 0,
created_at: 1700000000000,
},
],
proxy_config: null,
sort_order: 0,
created_at: 1700000000000,
updated_at: 1700000000000,
},
{
id: 'builtin-openai',
name: 'OpenAI',
@@ -208,7 +379,7 @@ const BUILT_IN_PROVIDERS = [
],
keys: [],
proxy_config: null,
sort_order: 0,
sort_order: 1,
created_at: 1700000000000,
updated_at: 1700000000000,
},
@@ -231,7 +402,7 @@ const BUILT_IN_PROVIDERS = [
],
keys: [],
proxy_config: null,
sort_order: 1,
sort_order: 2,
created_at: 1700000000000,
updated_at: 1700000000000,
},
@@ -252,7 +423,7 @@ const BUILT_IN_PROVIDERS = [
],
keys: [],
proxy_config: null,
sort_order: 2,
sort_order: 3,
created_at: 1700000000000,
updated_at: 1700000000000,
},
@@ -270,7 +441,7 @@ const BUILT_IN_PROVIDERS = [
],
keys: [],
proxy_config: null,
sort_order: 3,
sort_order: 4,
created_at: 1700000000000,
updated_at: 1700000000000,
},
@@ -289,7 +460,7 @@ const BUILT_IN_PROVIDERS = [
],
keys: [],
proxy_config: null,
sort_order: 4,
sort_order: 5,
created_at: 1700000000000,
updated_at: 1700000000000,
},
@@ -307,7 +478,7 @@ const BUILT_IN_PROVIDERS = [
],
keys: [],
proxy_config: null,
sort_order: 5,
sort_order: 6,
created_at: 1700000000000,
updated_at: 1700000000000,
},
@@ -634,6 +805,15 @@ export async function handleCommand<T>(cmd: string, args?: Record<string, unknow
}
case 'validate_provider_key':
return true as T;
case 'get_decrypted_provider_key': {
const { keyId } = args as any;
const providers = getStore<any[]>('providers', []);
for (const p of providers) {
const key = p.keys?.find((k: any) => k.id === keyId);
if (key) return key.key_encrypted as T;
}
throw new Error('Provider key not found');
}
case 'save_models': {
const { providerId, models } = args as any;
const providers = getStore<any[]>('providers', []);
+244
View File
@@ -0,0 +1,244 @@
import { invoke } from '@/lib/invoke';
import type { ProviderConfig } from '@/types';
export type JimengJobStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string;
export interface JimengAsset {
publicId: string;
status: string;
fileUrl: string;
}
export interface JimengJob {
id: string;
type: 'image' | 'video' | string;
title: string;
status: JimengJobStatus;
message: string | null;
error: string | null;
progress: number;
progressTotal: number;
result: {
assets?: JimengAsset[];
[key: string]: unknown;
} | null;
createdAt: string;
updatedAt: string;
startedAt: string | null;
finishedAt: string | null;
}
export interface JimengStatus {
success: boolean;
enabled: boolean;
role: string;
userId: number | null;
keyName: string | null;
capabilities: {
image: { reqKey: string };
video: { reqKey: string };
};
}
export interface CreateJimengImageJobInput {
prompt: string;
model?: string;
image_urls?: string[];
ratio?: string;
width?: number;
height?: number;
size?: string;
scale?: number;
min_ratio?: number;
max_ratio?: number;
force_single?: boolean;
}
export interface CreateJimengVideoJobInput {
prompt?: string;
model?: string;
image_urls?: string[];
frames?: 121 | 241;
aspect_ratio?: string;
seed?: number;
}
interface JimengApiResponse<T> {
success: boolean;
job?: T;
jobs?: T[];
[key: string]: unknown;
}
export interface JimengClientConfig {
provider: ProviderConfig;
apiKey: string;
}
type JsonObject = Record<string, unknown>;
function stripKnownOpenAiSuffix(pathname: string): string {
return pathname
.replace(/\/v\d+(?:beta\d*|alpha\d*)?\/(?:chat\/completions|responses|models)?\/?$/i, '')
.replace(/\/(?:chat\/completions|responses|models)\/?$/i, '')
.replace(/\/$/, '');
}
export function resolveBoosApiOrigin(provider: ProviderConfig): string {
const raw = provider.api_host.trim();
const url = new URL(raw.startsWith('http') ? raw : `https://${raw}`);
url.pathname = stripKnownOpenAiSuffix(url.pathname);
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}
export function findBoosApiProvider(providers: ProviderConfig[]): ProviderConfig | null {
const enabled = providers.filter((provider) => provider.enabled);
return (
enabled.find((provider) => /boosapi/i.test(provider.name)) ??
enabled.find((provider) => /api\.boos\.lat|tybossai\.com/i.test(provider.api_host)) ??
enabled[0] ??
null
);
}
export async function getJimengClientConfig(providers: ProviderConfig[]): Promise<JimengClientConfig> {
const provider = findBoosApiProvider(providers);
if (!provider) {
throw new Error('先在服务商管理里导入或启用 BoosAPI 服务商');
}
const key = provider.keys.find((item) => item.enabled) ?? provider.keys[0];
if (!key) {
throw new Error(`服务商 ${provider.name} 没有可用 API Key`);
}
const apiKey = await invoke<string>('get_decrypted_provider_key', { keyId: key.id });
return { provider, apiKey };
}
function parseResponseBody(text: string): JsonObject {
if (!text.trim()) return {};
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' ? parsed as JsonObject : {};
} catch {
return {};
}
}
function textFallback(text: string): string {
return text
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 240);
}
function responseMessage(data: JsonObject, text: string, fallback: string): string {
const direct = data.error ?? data.message;
if (typeof direct === 'string' && direct.trim()) return direct.trim();
if (direct && typeof direct === 'object') {
const nested = direct as JsonObject;
if (typeof nested.message === 'string' && nested.message.trim()) return nested.message.trim();
if (typeof nested.error === 'string' && nested.error.trim()) return nested.error.trim();
}
const cleaned = textFallback(text);
return cleaned || fallback;
}
async function requestJimeng<T>(
config: JimengClientConfig,
path: string,
init: RequestInit = {},
): Promise<T> {
const response = await fetch(`${resolveBoosApiOrigin(config.provider)}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
...(init.headers ?? {}),
},
});
const text = await response.text();
const data = parseResponseBody(text);
if (!response.ok || data?.success === false) {
throw new Error(responseMessage(data, text, `即梦请求失败:HTTP ${response.status}`));
}
return data as T;
}
function assetUploadUrlFromResponse(data: any): string | null {
const asset = data?.asset ?? data;
return (
asset?.workspaceFileUrl ??
asset?.workspace_file_url ??
asset?.fileUrl ??
asset?.file_url ??
null
);
}
export async function uploadJimengInputAsset(config: JimengClientConfig, file: File): Promise<string> {
const formData = new FormData();
formData.set('file', file);
formData.set('purpose', 'jimeng_reference');
const response = await fetch(`${resolveBoosApiOrigin(config.provider)}/api/digital-human/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
body: formData,
});
const text = await response.text();
const data = parseResponseBody(text);
if (!response.ok || data?.success === false) {
throw new Error(responseMessage(data, text, `即梦参考图上传失败:HTTP ${response.status}`));
}
const url = assetUploadUrlFromResponse(data);
if (!url) {
throw new Error('即梦参考图上传响应缺少 workspace 链接');
}
return url;
}
export async function getJimengStatus(config: JimengClientConfig): Promise<JimengStatus> {
return requestJimeng<JimengStatus>(config, '/api/jimeng/status');
}
export async function listJimengJobs(config: JimengClientConfig): Promise<JimengJob[]> {
const data = await requestJimeng<JimengApiResponse<JimengJob>>(config, '/api/jimeng/jobs');
return data.jobs ?? [];
}
export async function getJimengJob(config: JimengClientConfig, id: string): Promise<JimengJob> {
const data = await requestJimeng<JimengApiResponse<JimengJob>>(config, `/api/jimeng/jobs/${encodeURIComponent(id)}`);
if (!data.job) throw new Error('即梦任务响应缺少 job');
return data.job;
}
export async function createJimengImageJob(
config: JimengClientConfig,
input: CreateJimengImageJobInput,
): Promise<JimengJob> {
const data = await requestJimeng<JimengApiResponse<JimengJob>>(config, '/api/jimeng/image/jobs', {
method: 'POST',
body: JSON.stringify(input),
});
if (!data.job) throw new Error('即梦图片任务响应缺少 job');
return data.job;
}
export async function createJimengVideoJob(
config: JimengClientConfig,
input: CreateJimengVideoJobInput,
): Promise<JimengJob> {
const data = await requestJimeng<JimengApiResponse<JimengJob>>(config, '/api/jimeng/video/jobs', {
method: 'POST',
body: JSON.stringify(input),
});
if (!data.job) throw new Error('即梦视频任务响应缺少 job');
return data.job;
}
+11 -8
View File
@@ -22,9 +22,12 @@ const PROVIDER_TYPES: ProviderType[] = [
'custom',
];
function readRequiredParam(params: URLSearchParams, key: string): string | null {
const value = params.get(key)?.trim();
return value ? value : null;
function readRequiredParam(params: URLSearchParams, ...keys: string[]): string | null {
for (const key of keys) {
const value = params.get(key)?.trim();
if (value) return value;
}
return null;
}
function isProviderType(value: string): value is ProviderType {
@@ -45,12 +48,12 @@ export function parseProviderDeepLink(rawUrl: string): ProviderDeepLinkPayload |
}
if (url.protocol !== 'boosapi:') return null;
if (getDeepLinkTarget(url) !== 'providers') return null;
if (!['providers', 'import-provider', 'provider'].includes(getDeepLinkTarget(url))) return null;
const name = readRequiredParam(url.searchParams, 'name');
const baseurl = readRequiredParam(url.searchParams, 'baseurl');
const apikey = readRequiredParam(url.searchParams, 'apikey');
const type = readRequiredParam(url.searchParams, 'type');
const name = readRequiredParam(url.searchParams, 'name', 'provider', 'provider_name') || 'BoosAPI';
const baseurl = readRequiredParam(url.searchParams, 'baseurl', 'base_url', 'api_url', 'url');
const apikey = readRequiredParam(url.searchParams, 'apikey', 'api_key', 'key');
const type = readRequiredParam(url.searchParams, 'type', 'provider_type') || 'openai';
if (!name || !baseurl || !apikey || !type || !isProviderType(type)) {
return null;
+34 -2
View File
@@ -1,6 +1,7 @@
import type { Skill } from '@/types';
export type SkillPresetCategory = 'video' | 'document' | 'cad' | 'media';
export type SkillPresetMode = 'image' | 'video' | 'music' | 'voice' | 'document' | 'digital-human' | 'action';
export interface SkillPreset {
key: string;
@@ -10,6 +11,7 @@ export interface SkillPreset {
description: string;
promptPrefix: string;
category: SkillPresetCategory;
modes: SkillPresetMode[];
}
export const SKILL_PRESETS: SkillPreset[] = [
@@ -21,6 +23,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '创建剪映草稿、时间线、字幕和基础剪辑。',
promptPrefix: '请使用 jianying-editor Skill 自动创建剪映草稿:',
category: 'video',
modes: ['video'],
},
{
key: 'gorden-ppt',
@@ -30,6 +33,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '生成中文 PPT,保持版式并导出 PowerPoint 成品。',
promptPrefix: '请使用 gorden-ppt-skill 生成一份中文 PPT:',
category: 'document',
modes: ['document'],
},
{
key: 'knowledge-cat-ppt',
@@ -39,6 +43,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '把知识库、文档、笔记和研究材料整理成结构化 PPT。',
promptPrefix: '请使用 knowledge-cat-ppt Skill 根据资料生成一份结构化 PPT:',
category: 'document',
modes: ['document'],
},
{
key: 'seedance-prompt',
@@ -48,6 +53,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '为 Seedance 2.0 生成镜头、运动和视频提示词。',
promptPrefix: '请使用 seedance-prompt-en Skill 优化下面的视频生成提示词:',
category: 'video',
modes: ['video', 'digital-human', 'action'],
},
{
key: 'seedance-video',
@@ -57,6 +63,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '生成 Seedance 2 视频任务方案与参数。',
promptPrefix: '请使用 seedance Skill 生成视频创作方案:',
category: 'video',
modes: ['video', 'digital-human'],
},
{
key: 'hyperframes',
@@ -66,6 +73,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '用 HTML/CSS/JS 动画确定性渲染产品视频或解释视频。',
promptPrefix: '请使用 hyperframes Skill 生成一段可渲染成 MP4 的动画视频:',
category: 'video',
modes: ['video'],
},
{
key: 'remotion-video',
@@ -75,6 +83,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '用 React/Remotion 制作模板化、可批量渲染的视频。',
promptPrefix: '请使用 remotion-video Skill 生成 Remotion 视频方案:',
category: 'video',
modes: ['video'],
},
{
key: 'video-use',
@@ -84,6 +93,17 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '根据素材自动剪辑、字幕、调色和合成成片。',
promptPrefix: '请使用 video-use Skill 将素材混剪成最终视频:',
category: 'video',
modes: ['video'],
},
{
key: 'openmontage',
name: 'openmontage',
source: 'calesthio/OpenMontage',
title: 'OpenMontage',
description: '用 agentic pipeline 做素材研究、脚本、资产、剪辑与合成。',
promptPrefix: '请使用 OpenMontage Skill 规划并生成一条完整视频制作流水线:',
category: 'video',
modes: ['video'],
},
{
key: 'ffmpeg-media',
@@ -93,6 +113,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '分析、压缩、裁剪、转码和抽帧音视频素材。',
promptPrefix: '请使用 ffmpeg-media Skill 处理下面的音视频任务:',
category: 'media',
modes: ['video', 'music', 'voice'],
},
{
key: 'manim-animation',
@@ -102,6 +123,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '生成数学、知识类讲解动画脚本与渲染方案。',
promptPrefix: '请使用 manim-animation Skill 制作一段知识讲解动画:',
category: 'video',
modes: ['video'],
},
{
key: 'dwg-mcp-server',
@@ -111,6 +133,7 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '读取 DWG 图纸,分析图层、块、实体和属性。',
promptPrefix: '请使用 dwg-mcp-server Skill 分析这份 CAD/DWG 图纸:',
category: 'cad',
modes: ['document'],
},
{
key: 'cad-ai-agent',
@@ -120,11 +143,21 @@ export const SKILL_PRESETS: SkillPreset[] = [
description: '做 CAD/DXF 图纸摘要、检查、工程量和安全编辑计划。',
promptPrefix: '请使用 cad-ai-agent Skill 处理下面的 CAD 任务:',
category: 'cad',
modes: ['document'],
},
];
export function findSkillByPreset(skills: Skill[], preset: SkillPreset): Skill | null {
return skills.find((skill) => skill.name === preset.name) ?? null;
const normalizedName = preset.name.toLowerCase();
const normalizedSource = preset.source.toLowerCase();
return skills.find((skill) => {
const skillName = skill.name.toLowerCase();
const sourcePath = skill.sourcePath.toLowerCase();
return skillName === normalizedName
|| skillName.includes(normalizedName)
|| sourcePath.includes(normalizedName)
|| sourcePath.includes(normalizedSource);
}) ?? null;
}
export function getSkillPresetStatus(skills: Skill[], preset: SkillPreset): 'enabled' | 'installed' | 'missing' {
@@ -132,4 +165,3 @@ export function getSkillPresetStatus(skills: Skill[], preset: SkillPreset): 'ena
if (!skill) return 'missing';
return skill.enabled ? 'enabled' : 'installed';
}
+47 -10
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { App, Button, Card, Input, Radio, Space, Tag, Typography, theme } from 'antd';
import { App, Button, Card, Input, Radio, Tag, Typography, theme } from 'antd';
import { BookOpen, FileText, Presentation, Sparkles, Wrench } from 'lucide-react';
import { ModelSelect, parseModelValue } from '@/components/shared/ModelSelect';
import { invoke } from '@/lib/invoke';
import {
findSkillByPreset,
@@ -39,6 +40,7 @@ export function DocumentsPage() {
const setActivePage = useUIStore((s) => s.setActivePage);
const [selectedPresetKey, setSelectedPresetKey] = useState('gorden-ppt');
const [prompt, setPrompt] = useState('');
const [selectedModelValue, setSelectedModelValue] = useState<string | undefined>();
const [submitting, setSubmitting] = useState(false);
const [installingKey, setInstallingKey] = useState<string | null>(null);
@@ -67,6 +69,33 @@ export function DocumentsPage() {
return null;
}, [providers]);
const selectedChatModel = useMemo(() => {
const parsed = parseModelValue(selectedModelValue);
if (!parsed) return null;
const provider = providers.find((item) => item.enabled && item.id === parsed.providerId);
const model = provider?.models.find((item) => item.model_id === parsed.modelId && isUsableChatModel(item));
if (!provider || !model) return null;
return {
providerId: provider.id,
modelId: model.model_id,
providerName: provider.name,
modelName: model.name || model.model_id,
};
}, [providers, selectedModelValue]);
const defaultChatModelValue = defaultChatModel
? `${defaultChatModel.providerId}::${defaultChatModel.modelId}`
: undefined;
useEffect(() => {
if (!defaultChatModelValue) return;
if (!selectedModelValue || !selectedChatModel) {
setSelectedModelValue(defaultChatModelValue);
}
}, [defaultChatModelValue, selectedChatModel, selectedModelValue]);
const activeChatModel = selectedChatModel ?? defaultChatModel;
const ensureSkillReady = useCallback(async (preset: SkillPreset) => {
const existing = findSkillByPreset(useSkillStore.getState().skills, preset);
if (!existing) {
@@ -91,7 +120,7 @@ export function DocumentsPage() {
return;
}
if (!selectedPreset || submitting) return;
if (!defaultChatModel) {
if (!activeChatModel) {
message.warning('先在服务商管理里启用一个可对话模型');
return;
}
@@ -101,13 +130,13 @@ export function DocumentsPage() {
await ensureSkillReady(selectedPreset);
const conversation = await createConversation(
`${selectedPreset.title} · ${text.slice(0, 18)}`,
defaultChatModel.modelId,
defaultChatModel.providerId,
activeChatModel.modelId,
activeChatModel.providerId,
);
await updateConversation(conversation.id, {
mode: 'agent',
provider_id: defaultChatModel.providerId,
model_id: defaultChatModel.modelId,
provider_id: activeChatModel.providerId,
model_id: activeChatModel.modelId,
});
await invoke('agent_update_session', {
conversationId: conversation.id,
@@ -124,7 +153,7 @@ export function DocumentsPage() {
}
}, [
createConversation,
defaultChatModel,
activeChatModel,
ensureSkillReady,
message,
prompt,
@@ -180,10 +209,18 @@ export function DocumentsPage() {
placeholder="例如:根据 BossAI 的产品介绍生成一份 12 页融资路演 PPT,包含市场、产品、技术架构、商业模式和路线图。"
/>
<div className="documents-compose-footer">
<Space size={8} wrap>
<div className="documents-compose-meta">
<Tag>{selectedPreset?.name}</Tag>
<Tag>{defaultChatModel ? `${defaultChatModel.providerName} · ${defaultChatModel.modelName}` : '未选择对话模型'}</Tag>
</Space>
<span className="documents-model-label">对话模型</span>
<ModelSelect
value={selectedModelValue}
onChange={setSelectedModelValue}
placeholder="选择对话模型"
allowClear={false}
filterModel={isUsableChatModel}
style={{ minWidth: 280, maxWidth: 460, flex: '1 1 280px' }}
/>
</div>
<Button
type="primary"
icon={<Wrench size={16} />}
+149 -8
View File
@@ -13,6 +13,12 @@ import { useTranslation } from 'react-i18next';
import { useSkillStore } from '@/stores';
import type { Skill, MarketplaceSkill } from '@/types';
import { CopyButton } from '@/components/common/CopyButton';
import {
findSkillByPreset,
getSkillPresetStatus,
SKILL_PRESETS,
type SkillPreset,
} from '@/lib/skillPresets';
const INSTALL_TARGETS = [
{ key: 'boosapi', label: '~/.boosapi/skills/', desc: 'BoosAPI Desktop', icon: <Sparkles size={14} /> },
@@ -265,6 +271,90 @@ function MarketplaceCard({
);
}
function PresetSkillCard({
preset,
status,
installing,
onInstall,
onOpenGithub,
}: {
preset: SkillPreset;
status: 'enabled' | 'installed' | 'missing';
installing: string | null;
onInstall: (preset: SkillPreset, target: string) => void;
onOpenGithub: (source: string) => void;
}) {
const categoryLabel = preset.category === 'document'
? '文档'
: preset.category === 'cad'
? 'CAD'
: preset.category === 'media'
? '媒体'
: '视频';
const installed = status !== 'missing';
return (
<Card
size="small"
className="skill-card-hover"
style={{ marginBottom: 8 }}
styles={{ body: { padding: '12px 16px' } }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text strong className="skill-card-title">{preset.title}</Text>
<Tag>{categoryLabel}</Tag>
{status === 'enabled' && <Tag color="green">已启用</Tag>}
{status === 'installed' && <Tag color="blue">已安装</Tag>}
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 2 }}>
{preset.description}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{preset.source}</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Button
size="small"
type="text"
icon={<Github size={14} />}
onClick={() => onOpenGithub(preset.source)}
>
GitHub
</Button>
{installed && status === 'enabled' ? (
<Button size="small" disabled>
已可用
</Button>
) : (
<Dropdown
menu={{
items: INSTALL_TARGETS.map((target) => ({
key: target.key,
icon: target.icon,
label: `${target.desc} (${target.label})`,
})),
onClick: ({ key }) => onInstall(preset, key),
}}
trigger={['click']}
disabled={installing === preset.key}
>
<Button
size="small"
type={status === 'installed' ? 'default' : 'primary'}
loading={installing === preset.key}
icon={<Download size={14} />}
>
{status === 'installed' ? '启用' : '安装'}
</Button>
</Dropdown>
)}
</div>
</div>
</Card>
);
}
export function SkillsPage() {
const { t } = useTranslation();
const { token } = theme.useToken();
@@ -331,6 +421,29 @@ export function SkillsPage() {
}
}, [installSkill, messageApi, t]);
const handleInstallPreset = useCallback(async (preset: SkillPreset, target: string) => {
if (installing) return;
setInstalling(preset.key);
try {
const existingSkill = findSkillByPreset(skills, preset);
if (existingSkill && !existingSkill.enabled) {
await toggleSkill(existingSkill.name, true);
messageApi.success(`${preset.title} Skill 已启用`);
} else if (!existingSkill) {
const name = await installSkill(preset.source, target);
messageApi.success(t('skills.installSuccess', { name }));
}
} catch (e) {
messageApi.error(String(e));
} finally {
setInstalling(null);
}
}, [installSkill, installing, messageApi, skills, t, toggleSkill]);
const handleOpenPresetGithub = useCallback((source: string) => {
openExternalUrl(`https://github.com/${source}`);
}, []);
const handleToggle = useCallback((name: string, enabled: boolean) => {
toggleSkill(name, enabled);
}, [toggleSkill]);
@@ -394,6 +507,13 @@ export function SkillsPage() {
return skills.filter(s => s.source === sourceFilter);
}, [skills, sourceFilter]);
const recommendedSkillPresets = useMemo(() => (
SKILL_PRESETS.map((preset) => ({
preset,
status: getSkillPresetStatus(skills, preset),
}))
), [skills]);
const groupedSkills = useMemo(() => {
const groups = new Map<string, Skill[]>();
const ungrouped: Skill[] = [];
@@ -505,15 +625,36 @@ export function SkillsPage() {
<Spin />
</div>
) : filteredSkills.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<div>{t('skills.empty')}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{t('skills.emptyDesc')}</Text>
(sourceFilter === 'all' || sourceFilter === 'boosapi') ? (
<div>
<div style={{ margin: '8px 0 12px' }}>
<Text strong>BoosAPI 内置推荐技能</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 2 }}>
这些技能已内置到项目清单里,点击即可安装到 Desktop 并在 Agent 对话中调用。
</Text>
</div>
}
/>
{recommendedSkillPresets.map(({ preset, status }) => (
<PresetSkillCard
key={preset.key}
preset={preset}
status={status}
installing={installing}
onInstall={handleInstallPreset}
onOpenGithub={handleOpenPresetGithub}
/>
))}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<div>{t('skills.empty')}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{t('skills.emptyDesc')}</Text>
</div>
}
/>
)
) : (
<>
{groupedSkills.ungrouped.map((skill) => (
@@ -113,7 +113,7 @@ describe('conversationStore agent streaming', () => {
assistantMessageId: secondAssistantId,
text: 'new answer',
});
vi.advanceTimersByTime(20);
vi.advanceTimersByTime(60);
const messages = useConversationStore.getState().messages;
expect(messages.find((message) => message.id === firstAssistantId)?.content).toBe('');
+106 -13
View File
@@ -10,6 +10,9 @@ import type {
AskUserEvent,
AgentStatusEvent,
AgentDoneEvent,
AgentErrorEvent,
AgentCancelledEvent,
AgentRunStartedEvent,
} from '@/types/agent';
import type { ToolExecution } from '@/types/mcp';
@@ -23,6 +26,7 @@ interface QueryStats {
interface AgentStore {
// Session cache (truth lives in backend DB)
sessions: Record<string, AgentSession>;
activeRunIds: Record<string, string>;
// Runtime state
agentStatus: Record<string, string>; // conversationId → status message
@@ -36,7 +40,10 @@ interface AgentStore {
fetchSession: (conversationId: string) => Promise<AgentSession | null>;
updateCwd: (conversationId: string, cwd: string) => Promise<void>;
updatePermissionMode: (conversationId: string, mode: string) => Promise<void>;
updateHostAccess: (conversationId: string, enabled: boolean) => Promise<void>;
approveToolUse: (conversationId: string, toolUseId: string, decision: string) => Promise<void>;
setActiveRun: (conversationId: string, runId: string) => void;
finishRun: (conversationId: string, runId: string) => void;
// Event handlers
handleToolUse: (event: ToolUseEvent) => void;
@@ -47,7 +54,7 @@ interface AgentStore {
handleAskUser: (event: AskUserEvent) => void;
handleAskUserResolved: (askId: string) => void;
respondAskUser: (askId: string, answer: string) => Promise<void>;
handleStatus: (conversationId: string, message: string) => void;
handleStatus: (conversationId: string, runId: string | undefined, message: string) => void;
clearStatus: (conversationId: string) => void;
handleDone: (event: AgentDoneEvent) => void;
@@ -60,6 +67,7 @@ interface AgentStore {
export const useAgentStore = create<AgentStore>((set, get) => ({
sessions: {},
activeRunIds: {},
agentStatus: {},
pendingPermissions: {},
pendingAskUser: {},
@@ -95,6 +103,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
}));
} catch (e) {
console.error('[agentStore] updateCwd failed:', e);
throw e;
}
},
@@ -109,25 +118,66 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
}));
} catch (e) {
console.error('[agentStore] updatePermissionMode failed:', e);
throw e;
}
},
updateHostAccess: async (conversationId, enabled) => {
try {
const session = await invoke<AgentSession>('agent_update_session', {
conversationId,
hostAccessEnabled: enabled,
});
set((state) => ({
sessions: { ...state.sessions, [conversationId]: session },
}));
} catch (error) {
console.error('[agentStore] updateHostAccess failed:', error);
throw error;
}
},
setActiveRun: (conversationId, runId) => {
set((state) => ({
activeRunIds: { ...state.activeRunIds, [conversationId]: runId },
}));
},
finishRun: (conversationId, runId) => {
set((state) => {
if (state.activeRunIds[conversationId] !== runId) return state;
const { [conversationId]: _run, ...activeRunIds } = state.activeRunIds;
const { [conversationId]: _status, ...agentStatus } = state.agentStatus;
return { activeRunIds, agentStatus };
});
},
approveToolUse: async (conversationId, toolUseId, decision) => {
try {
const request = get().pendingPermissions[toolUseId];
if (!request || request.conversationId !== conversationId) {
throw new Error('Permission request is no longer active');
}
await invoke('agent_approve', {
conversationId,
runId: request.runId,
toolUseId,
decision,
});
get().handlePermissionResolved(toolUseId, decision);
} catch (e) {
console.error('[agentStore] approveToolUse failed:', e);
throw e;
}
},
handleToolUse: (event) => {
set((s) => {
if (event.runId && s.activeRunIds[event.conversationId] !== event.runId) return s;
const eventRunId = event.runId ?? s.activeRunIds[event.conversationId] ?? '';
const toolCall: ToolCallState = {
conversationId: event.conversationId,
runId: eventRunId,
toolUseId: event.toolUseId,
toolName: event.toolName,
input: event.input,
@@ -152,8 +202,12 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
handleToolStart: (event) => {
set((s) => {
if (event.runId && s.activeRunIds[event.conversationId] !== event.runId) return s;
const eventRunId = event.runId ?? s.activeRunIds[event.conversationId] ?? '';
const existing = s.toolCalls[event.toolUseId];
const updated: ToolCallState = {
conversationId: event.conversationId,
runId: eventRunId,
toolUseId: event.toolUseId,
toolName: event.toolName,
input: event.input,
@@ -174,9 +228,13 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
handleToolResult: (event) => {
set((s) => {
if (event.runId && s.activeRunIds[event.conversationId] !== event.runId) return s;
const eventRunId = event.runId ?? s.activeRunIds[event.conversationId] ?? '';
const existing = s.toolCalls[event.toolUseId];
const newStatus = event.isError ? 'failed' : 'success';
const updated: ToolCallState = {
conversationId: event.conversationId,
runId: eventRunId,
toolUseId: event.toolUseId,
toolName: event.toolName || existing?.toolName || '',
input: existing?.input ?? {},
@@ -198,9 +256,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
},
handlePermissionRequest: (event) => {
set((s) => ({
set((s) => (!event.runId || s.activeRunIds[event.conversationId] === event.runId) ? ({
pendingPermissions: { ...s.pendingPermissions, [event.toolUseId]: event },
}));
}) : s);
},
handlePermissionResolved: (toolUseId, decision) => {
@@ -224,9 +282,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
},
handleAskUser: (event) => {
set((s) => ({
set((s) => (!event.runId || s.activeRunIds[event.conversationId] === event.runId) ? ({
pendingAskUser: { ...s.pendingAskUser, [event.askId]: event },
}));
}) : s);
},
handleAskUserResolved: (askId) => {
@@ -238,19 +296,25 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
respondAskUser: async (askId, answer) => {
try {
await invoke('agent_respond_ask', { askId, answer });
// Brief delay so user sees the loading/submitted feedback
await new Promise((r) => setTimeout(r, 500));
const request = get().pendingAskUser[askId];
if (!request) throw new Error('Question is no longer active');
await invoke('agent_respond_ask', {
conversationId: request.conversationId,
runId: request.runId,
askId,
answer,
});
get().handleAskUserResolved(askId);
} catch (e) {
console.error('[agentStore] respondAskUser failed:', e);
throw e;
}
},
handleStatus: (conversationId, message) => {
set((s) => ({
handleStatus: (conversationId, runId, message) => {
set((s) => (!runId || s.activeRunIds[conversationId] === runId) ? ({
agentStatus: { ...s.agentStatus, [conversationId]: message },
}));
}) : s);
},
clearStatus: (conversationId) => {
@@ -308,6 +372,8 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
}
toolCalls[exec.id] = {
conversationId,
runId: '',
toolUseId: exec.id,
toolName: exec.toolName,
input,
@@ -330,6 +396,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
clearConversation: (conversationId) => {
set((s) => {
const { [conversationId]: _session, ...sessions } = s.sessions;
const { [conversationId]: _run, ...activeRunIds } = s.activeRunIds;
const { [conversationId]: _status, ...agentStatus } = s.agentStatus;
const pendingPermissions: Record<string, PermissionRequestEvent> = {};
@@ -349,7 +416,10 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
// ToolCallState doesn't carry conversationId; filter via pendingPermissions
// that were already associated with this conversation. A more thorough
// cleanup happens naturally as the conversation is no longer active.
return { sessions, agentStatus, pendingPermissions, pendingAskUser };
const toolCalls = Object.fromEntries(
Object.entries(s.toolCalls).filter(([, call]) => call.conversationId !== conversationId),
);
return { sessions, activeRunIds, agentStatus, pendingPermissions, pendingAskUser, toolCalls };
});
},
}));
@@ -360,6 +430,12 @@ export function setupAgentEventListeners(): () => void {
const unlisteners: Promise<UnlistenFn>[] = [];
const store = useAgentStore.getState();
unlisteners.push(
listen<AgentRunStartedEvent>('agent-run-started', (event) => {
store.setActiveRun(event.payload.conversationId, event.payload.runId);
}),
);
unlisteners.push(
listen<ToolUseEvent>('agent-tool-use', (event) => {
store.handleToolUse(event.payload);
@@ -392,14 +468,31 @@ export function setupAgentEventListeners(): () => void {
unlisteners.push(
listen<AgentStatusEvent>('agent-status', (event) => {
store.handleStatus(event.payload.conversationId, event.payload.message);
store.handleStatus(event.payload.conversationId, event.payload.runId, event.payload.message);
}),
);
unlisteners.push(
listen<AgentDoneEvent>('agent-done', (event) => {
if (event.payload.runId && useAgentStore.getState().activeRunIds[event.payload.conversationId] !== event.payload.runId) return;
store.clearStatus(event.payload.conversationId);
store.handleDone(event.payload);
const runId = event.payload.runId ?? useAgentStore.getState().activeRunIds[event.payload.conversationId];
if (runId) store.finishRun(event.payload.conversationId, runId);
}),
);
unlisteners.push(
listen<AgentErrorEvent>('agent-error', (event) => {
const runId = event.payload.runId ?? useAgentStore.getState().activeRunIds[event.payload.conversationId];
if (runId) store.finishRun(event.payload.conversationId, runId);
}),
);
unlisteners.push(
listen<AgentCancelledEvent>('agent-cancelled', (event) => {
const runId = event.payload.runId ?? useAgentStore.getState().activeRunIds[event.payload.conversationId];
if (runId) store.finishRun(event.payload.conversationId, runId);
}),
);
+41 -8
View File
@@ -19,6 +19,7 @@ import { useKnowledgeStore } from '@/stores/knowledgeStore';
import { useMemoryStore } from '@/stores/memoryStore';
import { useMcpStore } from '@/stores/mcpStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { useAgentStore } from '@/stores/agentStore';
import { useCategoryStore } from './categoryStore';
import type {
Conversation,
@@ -40,6 +41,7 @@ import type {
AgentErrorEvent,
AgentStreamTextEvent,
AgentStreamThinkingEvent,
AgentCancelledEvent,
} from '@/types';
let _unlisten: UnlistenFn | null = null;
@@ -67,7 +69,8 @@ let _streamPrefix = '';
const _pendingConversationRefresh = new Set<string>();
const STREAM_UI_FLUSH_INTERVAL_MS = 32;
const STREAM_FIRST_UI_FLUSH_DELAY_MS = 0;
const AGENT_STREAM_UI_FLUSH_INTERVAL_MS = 16;
const AGENT_STREAM_UI_FLUSH_INTERVAL_MS = 50;
const AGENT_STREAM_UI_FLUSH_BYTES = 4 * 1024;
const ACTIVE_STREAM_EXISTS_ERROR_FRAGMENT = '当前会话已有回复正在生成';
interface PendingUiChunk {
messageId: string;
@@ -2237,6 +2240,9 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
_activeAgentCancel = null;
const agentRunSeq = ++_agentStreamSeq;
const isCurrentAgentRun = () => agentRunSeq === _agentStreamSeq;
const runId = globalThis.crypto?.randomUUID?.()
?? `desktop-run-${Date.now()}-${Math.random().toString(16).slice(2)}`;
useAgentStore.getState().setActiveRun(conversationId, runId);
const conversation = get().conversations.find((c) => c.id === conversationId);
if (!conversation) throw new Error('Conversation not found');
@@ -2312,6 +2318,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
let unlistenStreamText: UnlistenFn | null = null;
let unlistenStreamThinking: UnlistenFn | null = null;
let unlistenMessageId: UnlistenFn | null = null;
let unlistenCancelled: UnlistenFn | null = null;
let cancelActiveRun: (() => void) | null = null;
let cleanedUp = false;
@@ -2374,6 +2381,10 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
};
const scheduleAgentFlush = () => {
if (_agentPendingText.length + _agentPendingThinking.length >= AGENT_STREAM_UI_FLUSH_BYTES) {
flushAgentStreamChunks();
return;
}
if (_agentFlushTimer === null) {
_agentFlushTimer = setTimeout(flushAgentStreamChunks, AGENT_STREAM_UI_FLUSH_INTERVAL_MS);
}
@@ -2396,11 +2407,13 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
unlistenDone?.();
unlistenError?.();
unlistenMessageId?.();
unlistenCancelled?.();
unlistenStreamText = null;
unlistenStreamThinking = null;
unlistenDone = null;
unlistenError = null;
unlistenMessageId = null;
unlistenCancelled = null;
if (_activeAgentCancel === cancelActiveRun) {
_activeAgentCancel = null;
}
@@ -2427,8 +2440,8 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
// Listen for the real assistant message ID from the backend
// This replaces the temp ID so tool call events can be matched
listen<{ conversationId: string; assistantMessageId: string }>('agent-message-id', (event) => {
if (event.payload.conversationId !== conversationId || !isCurrentAgentRun()) return;
listen<{ conversationId: string; runId?: string; assistantMessageId: string }>('agent-message-id', (event) => {
if (event.payload.conversationId !== conversationId || (event.payload.runId && event.payload.runId !== runId) || !isCurrentAgentRun()) return;
// Flush pending buffer before switching IDs
flushAgentStreamChunks();
const realId = event.payload.assistantMessageId;
@@ -2444,21 +2457,21 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
// Listen for incremental text chunks — buffer and flush periodically
listen<AgentStreamTextEvent>('agent-stream-text', (event) => {
if (event.payload.conversationId !== conversationId || !isCurrentAgentRun()) return;
if (event.payload.conversationId !== conversationId || (event.payload.runId && event.payload.runId !== runId) || !isCurrentAgentRun()) return;
_agentPendingText += event.payload.text;
scheduleAgentFlush();
}).then(keepAgentUnlisten((fn) => { unlistenStreamText = fn; }));
// Listen for incremental thinking chunks — buffer and flush periodically
listen<AgentStreamThinkingEvent>('agent-stream-thinking', (event) => {
if (event.payload.conversationId !== conversationId || !isCurrentAgentRun()) return;
if (event.payload.conversationId !== conversationId || (event.payload.runId && event.payload.runId !== runId) || !isCurrentAgentRun()) return;
_agentPendingThinking += event.payload.thinking;
scheduleAgentFlush();
}).then(keepAgentUnlisten((fn) => { unlistenStreamThinking = fn; }));
// Listen for agent-done — correction overwrite with final content
listen<AgentDoneEvent>('agent-done', (event) => {
if (event.payload.conversationId !== conversationId || !isCurrentAgentRun()) return;
if (event.payload.conversationId !== conversationId || (event.payload.runId && event.payload.runId !== runId) || !isCurrentAgentRun()) return;
// Clear pending buffer (done event overwrites with final content)
clearAgentStreamBuffer();
const isActiveConversation = get().activeConversationId === conversationId;
@@ -2510,7 +2523,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
// Listen for agent-error
listen<AgentErrorEvent>('agent-error', (event) => {
if (event.payload.conversationId !== conversationId || !isCurrentAgentRun()) return;
if (event.payload.conversationId !== conversationId || (event.payload.runId && event.payload.runId !== runId) || !isCurrentAgentRun()) return;
// Clear pending buffer (error event overwrites content)
clearAgentStreamBuffer();
// Skip if streaming was already cancelled
@@ -2546,6 +2559,23 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
cleanup();
reject(new Error(event.payload.message));
}).then(keepAgentUnlisten((fn) => { unlistenError = fn; }));
listen<AgentCancelledEvent>('agent-cancelled', (event) => {
if (event.payload.conversationId !== conversationId || (event.payload.runId && event.payload.runId !== runId) || !isCurrentAgentRun()) return;
flushAgentStreamChunks();
set((state) => ({
streaming: false,
streamingMessageId: null,
streamingConversationId: null,
activeStreamId: null,
thinkingActiveMessageIds: new Set<string>(),
messages: state.messages.map((message) => message.id === currentMsgId
? { ...message, status: 'partial' as const }
: message),
}));
cleanup();
resolve();
}).then(keepAgentUnlisten((fn) => { unlistenCancelled = fn; }));
});
// Invoke the backend command (this creates the real user message in DB)
@@ -2555,6 +2585,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
providerId,
modelId,
attachments: attachments ?? [],
runId,
});
// Wait for agent-done or agent-error event
@@ -2563,6 +2594,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
cleanup();
const errMsg = String(e);
console.error('[sendAgentMessage] error:', errMsg);
useAgentStore.getState().finishRun(conversationId, runId);
// If streaming is still true, the error came from invoke itself (not an event)
if (get().streaming && (get().streamingMessageId === currentMsgId)) {
@@ -3695,7 +3727,8 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
// Also cancel the agent if in agent mode
const conv = get().conversations.find((c) => c.id === conversationId);
if (conv?.mode === 'agent') {
invoke('agent_cancel', { conversationId }).catch(() => {});
const runId = useAgentStore.getState().activeRunIds[conversationId];
invoke('agent_cancel', { conversationId, runId }).catch(() => {});
}
}
// Mark the current streaming message as partial
+28 -2
View File
@@ -1,6 +1,6 @@
export type AgentPermissionMode = 'default' | 'accept_edits' | 'full_access';
export type AgentRuntimeStatus = 'idle' | 'running' | 'waiting_approval' | 'completed' | 'error';
export type ApprovalStatus = 'pending' | 'approved' | 'denied';
export type AgentRuntimeStatus = 'idle' | 'active' | 'interrupted';
export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'expired';
export interface AgentSession {
id: string;
@@ -8,14 +8,27 @@ export interface AgentSession {
cwd?: string;
permission_mode: AgentPermissionMode;
runtime_status: AgentRuntimeStatus;
current_run_id?: string | null;
host_access_enabled: boolean;
revision: number;
context_version: number;
last_provider_id?: string | null;
last_model_id?: string | null;
total_tokens: number;
total_cost_usd: number;
}
// --- Event payload types (all tool-related events carry assistantMessageId anchor) ---
export interface AgentRunStartedEvent {
conversationId: string;
sessionId: string;
runId: string;
}
export interface ToolUseEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
toolUseId: string;
toolName: string;
@@ -25,6 +38,7 @@ export interface ToolUseEvent {
export interface ToolStartEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
toolUseId: string;
toolName: string;
@@ -33,6 +47,7 @@ export interface ToolStartEvent {
export interface ToolResultEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
toolUseId: string;
toolName: string;
@@ -42,6 +57,7 @@ export interface ToolResultEvent {
export interface PermissionRequestEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
toolUseId: string;
toolName: string;
@@ -51,6 +67,7 @@ export interface PermissionRequestEvent {
export interface AskUserEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
askId: string;
question: string;
@@ -59,6 +76,7 @@ export interface AskUserEvent {
export interface AgentDoneEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
text: string;
usage?: { input_tokens: number; output_tokens: number };
@@ -68,35 +86,41 @@ export interface AgentDoneEvent {
export interface AgentErrorEvent {
conversationId: string;
runId?: string;
assistantMessageId?: string;
message: string;
}
export interface AgentCancelledEvent {
conversationId: string;
runId?: string;
assistantMessageId?: string;
reason: string;
}
export interface AgentStatusEvent {
conversationId: string;
runId?: string;
message: string;
}
export interface AgentRateLimitEvent {
conversationId: string;
runId?: string;
retryAfterMs: number;
message: string;
}
export interface AgentStreamTextEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
text: string;
}
export interface AgentStreamThinkingEvent {
conversationId: string;
runId?: string;
assistantMessageId: string;
thinking: string;
}
@@ -104,6 +128,8 @@ export interface AgentStreamThinkingEvent {
// --- Frontend runtime state ---
export interface ToolCallState {
conversationId: string;
runId: string;
toolUseId: string;
toolName: string;
input: Record<string, unknown>;