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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', '取消'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -565,7 +565,7 @@
|
||||
"documentation": "文档",
|
||||
"openSource": "开源协议",
|
||||
"addProvider": "添加服务商",
|
||||
"deepLinkProviderConfirmTitle": "导入服务商配置",
|
||||
"deepLinkProviderConfirmTitle": "导入客户端",
|
||||
"deepLinkProviderCreated": "服务商已导入",
|
||||
"deepLinkProviderKeyAdded": "服务商已存在,密钥已添加",
|
||||
"deepLinkProviderReusedKey": "服务商与密钥已存在",
|
||||
|
||||
+145
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user