feat: add desktop document skills workspace

This commit is contained in:
boos
2026-06-29 14:35:34 +08:00
parent 2b181344b2
commit 258f4c1a00
11 changed files with 537 additions and 96 deletions
@@ -18,6 +18,7 @@ import {
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';
@@ -65,10 +66,6 @@ const JIMENG_VIDEO_MODELS = [
{ value: 'custom', label: '自定义 req_key' },
];
const JIANYING_SKILL_NAME = 'jianying-editor';
const JIANYING_SKILL_SOURCE = 'luoluoluo22/jianying-editor-skill';
const JIANYING_PROMPT_PREFIX = '请使用 jianying-editor Skill 自动创建剪映草稿:';
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
@@ -145,7 +142,7 @@ export function AgentWorkbenchComposer() {
const [subjectOpen, setSubjectOpen] = useState(false);
const [skillOpen, setSkillOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [installingJianying, setInstallingJianying] = useState(false);
const [installingSkillKey, setInstallingSkillKey] = useState<string | null>(null);
const [result, setResult] = useState('');
useEffect(() => {
@@ -212,9 +209,9 @@ export function AgentWorkbenchComposer() {
chatModelOptions.find((item) => item.value === agentModel) ?? null
), [agentModel, chatModelOptions]);
const jianyingSkill = useMemo(() => (
skills.find((item) => item.name === JIANYING_SKILL_NAME) ?? null
), [skills]);
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;
@@ -232,30 +229,33 @@ export function AgentWorkbenchComposer() {
setAttachments((current) => current.filter((item) => item.id !== id));
}, []);
const applyJianyingSkill = useCallback(async () => {
if (installingJianying) return;
setInstallingJianying(true);
const applySkillPreset = useCallback(async (preset: SkillPreset) => {
if (installingSkillKey) return;
setInstallingSkillKey(preset.key);
try {
if (!jianyingSkill) {
await installSkill(JIANYING_SKILL_SOURCE);
message.success('剪映 Skill 已安装');
} else if (!jianyingSkill.enabled) {
await toggleSkill(JIANYING_SKILL_NAME, true);
message.success('剪映 Skill 已启用');
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(JIANYING_SKILL_NAME) || text.includes('剪映')) return value;
return text ? `${JIANYING_PROMPT_PREFIX}${text}` : `${JIANYING_PROMPT_PREFIX}`;
if (text.includes(preset.name) || text.includes(preset.title)) return value;
return text ? `${preset.promptPrefix}${text}` : preset.promptPrefix;
});
setSkillOpen(false);
} catch (error) {
message.error(`剪映 Skill 集成失败:${String(error)}`);
message.error(`${preset.title} Skill 集成失败:${String(error)}`);
} finally {
setInstallingJianying(false);
setInstallingSkillKey(null);
}
}, [installSkill, installingJianying, jianyingSkill, message, toggleSkill]);
}, [installSkill, installingSkillKey, loadSkills, message, toggleSkill]);
const submit = useCallback(async () => {
const text = prompt.trim();
@@ -455,26 +455,29 @@ export function AgentWorkbenchComposer() {
dropdownRender={() => (
<div className="agent-workbench-popover">
<Input placeholder="搜索技能" prefix={<Wrench size={15} />} />
<button type="button" onClick={() => void applyJianyingSkill()}>
<Clapperboard size={16} />
剪映自动剪辑
<small>
{installingJianying
? '正在安装/启用...'
: jianyingSkill?.enabled
? '已启用,生成草稿、字幕、配音、特效和导出'
: jianyingSkill
? '已安装,点击启用并写入提示'
: '一键安装 luoluoluo22/jianying-editor-skill'}
</small>
</button>
<button type="button"><Wrench size={16} /> 剧情短片 <small>自动生成故事大纲、分镜脚本并产出短片</small></button>
<button type="button"><Wrench size={16} /> 电商套图 <small>生成风格统一的商品全套视觉素材</small></button>
{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" icon={<Wrench size={15} />}>使用技能</Button>
<Button className="agent-workbench-pill" loading={Boolean(installingSkillKey)} icon={<Wrench size={15} />}>使用技能</Button>
</Dropdown>
<Dropdown
open={subjectOpen}
+42 -34
View File
@@ -18,6 +18,8 @@ import { KnowledgeBaseIcon } from '@/components/shared/KnowledgeBaseIcon';
import { getShortcutBinding, formatShortcutForDisplay, matchesShortcutEvent } from '@/lib/shortcuts';
import { normalizeAutoConversationTitle } from '@/lib/conversationTitle';
import type { ShortcutAction } from '@/lib/shortcuts';
import { findSkillByPreset, getSkillPresetStatus, SKILL_PRESETS } from '@/lib/skillPresets';
import type { SkillPreset } from '@/lib/skillPresets';
import { VoiceCall } from './VoiceCall';
import { ConversationSettingsModal } from './ConversationSettingsModal';
import { ModelSelector } from './ModelSelector';
@@ -45,9 +47,6 @@ type ComposerCreationMode = 'chat' | 'agent' | 'image' | 'video' | 'music' | 'vo
const MEDIA_ATTACHMENT_ACCEPT = 'image/*,video/*,audio/*';
const ANY_ATTACHMENT_ACCEPT = `${MEDIA_ATTACHMENT_ACCEPT},${DOCUMENT_ATTACHMENT_ACCEPT}`;
const JIANYING_SKILL_NAME = 'jianying-editor';
const JIANYING_SKILL_SOURCE = 'luoluoluo22/jianying-editor-skill';
const JIANYING_PROMPT_PREFIX = '请使用 jianying-editor Skill 自动创建剪映草稿:';
const CREATIVE_PROMPT_PREFIX: Record<Exclude<ComposerCreationMode, 'chat' | 'agent'>, string> = {
image: '请使用 BoosAPI 的图片生成能力创作:',
video: '请使用 BoosAPI 的视频生成能力创作:',
@@ -112,7 +111,7 @@ export function InputArea() {
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<ComposerCreationMode>('chat');
const [skillsDropdownOpen, setSkillsDropdownOpen] = useState(false);
const [installingJianying, setInstallingJianying] = useState(false);
const [installingSkillKey, setInstallingSkillKey] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const valueRef = useRef(value);
@@ -254,9 +253,9 @@ export function InputArea() {
const activeConversation = conversations.find((c) => c.id === activeConversationId);
const currentMode = activeConversation?.mode || 'chat';
const selectedComposerMode: ComposerCreationMode = currentMode === 'agent' ? 'agent' : pendingMode;
const jianyingSkill = useMemo(() => (
skills.find((item) => item.name === JIANYING_SKILL_NAME) ?? null
), [skills]);
const skillMenuPresets = useMemo(() => SKILL_PRESETS.filter((preset) => (
preset.category === 'video' || preset.category === 'document' || preset.category === 'media'
)), []);
const setActivePage = useUIStore((s) => s.setActivePage);
const setSettingsSection = useUIStore((s) => s.setSettingsSection);
@@ -954,32 +953,33 @@ export function InputArea() {
});
}, [activeConversation, handleModeSwitch, updateConversation]);
const applyJianyingSkill = useCallback(async () => {
if (installingJianying) return;
setInstallingJianying(true);
const applySkillPreset = useCallback(async (preset: SkillPreset) => {
if (installingSkillKey) return;
setInstallingSkillKey(preset.key);
try {
if (!jianyingSkill) {
await installSkill(JIANYING_SKILL_SOURCE);
const existingSkill = findSkillByPreset(useSkillStore.getState().skills, preset);
if (!existingSkill) {
await installSkill(preset.source);
await loadSkills();
messageApi.success('剪映 Skill 已安装');
} else if (!jianyingSkill.enabled) {
await toggleSkill(JIANYING_SKILL_NAME, true);
messageApi.success(`${preset.title} Skill 已安装`);
} else if (!existingSkill.enabled) {
await toggleSkill(preset.name, true);
await loadSkills();
messageApi.success('剪映 Skill 已启用');
messageApi.success(`${preset.title} Skill 已启用`);
}
await handleModeSwitch('agent');
setValue((current) => {
const text = current.trim();
if (text.includes(JIANYING_SKILL_NAME) || text.includes('剪映')) return current;
return text ? `${JIANYING_PROMPT_PREFIX}${text}` : JIANYING_PROMPT_PREFIX;
if (text.includes(preset.name) || text.includes(preset.title)) return current;
return text ? `${preset.promptPrefix}${text}` : preset.promptPrefix;
});
setSkillsDropdownOpen(false);
} catch (error) {
messageApi.error(`剪映 Skill 集成失败:${String(error)}`);
messageApi.error(`${preset.title} Skill 集成失败:${String(error)}`);
} finally {
setInstallingJianying(false);
setInstallingSkillKey(null);
}
}, [handleModeSwitch, installSkill, installingJianying, jianyingSkill, loadSkills, messageApi, toggleSkill]);
}, [handleModeSwitch, installSkill, installingSkillKey, loadSkills, messageApi, toggleSkill]);
const handleSend = useCallback(async () => {
const trimmed = value.trim();
@@ -1782,19 +1782,27 @@ export function InputArea() {
}}
menu={{
items: [
{
key: 'jianying',
icon: <Wrench size={14} />,
label: (
<div>
<strong>剪映短片</strong>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
{jianyingSkill?.enabled ? '已启用,可直接用 Agent 创建剪映草稿' : '安装/启用 jianying-editor Skill'}
...skillMenuPresets.map((preset) => {
const status = getSkillPresetStatus(skills, preset);
return {
key: preset.key,
icon: <Wrench size={14} />,
label: (
<div>
<strong>{preset.title}</strong>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
{status === 'enabled'
? '已启用,可直接在 Agent 中使用'
: status === 'installed'
? '已安装,点击启用并插入提示词'
: `安装/启用 ${preset.name} Skill`}
</div>
</div>
</div>
),
onClick: () => void applyJianyingSkill(),
},
),
onClick: () => void applySkillPreset(preset),
};
}),
{ type: 'divider' as const },
{
key: 'skills-page',
icon: <BookOpen size={14} />,
@@ -1809,7 +1817,7 @@ export function InputArea() {
<Button
type="text"
size="small"
loading={installingJianying}
loading={Boolean(installingSkillKey)}
icon={<Wrench size={14} />}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}
>
@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Modal, Input, List, Tag, Typography, theme } from 'antd';
import { Search, MessageSquare, Settings, Network, Plus, PanelLeftClose, Sparkles, ImagePlus } from 'lucide-react';
import { Search, MessageSquare, Settings, Plus, PanelLeftClose, Sparkles, ImagePlus, FileText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useUIStore } from '@/stores';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
@@ -61,11 +61,11 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
action: () => { setActivePage('drawing'); onClose(); },
},
{
id: 'go-gateway',
label: t('commandPalette.goToGateway'),
icon: <Network size={16} />,
id: 'go-documents',
label: t('commandPalette.goToDocuments'),
icon: <FileText size={16} />,
category: nav,
action: () => { setActivePage('gateway'); onClose(); },
action: () => { setActivePage('documents'); onClose(); },
},
{
id: 'go-skills',
@@ -3,10 +3,10 @@ import { ChatPage } from '@/pages/ChatPage';
import { DrawingPage } from '@/pages/DrawingPage';
import { KnowledgePage } from '@/pages/KnowledgePage';
import { MemoryPage } from '@/pages/MemoryPage';
import { GatewayPage } from '@/pages/GatewayPage';
import { FilesPage } from '@/pages/FilesPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { SkillsPage } from '@/pages/SkillsPage';
import { DocumentsPage } from '@/pages/DocumentsPage';
interface ContentAreaProps {
activePage: PageKey;
@@ -22,14 +22,14 @@ export function ContentArea({ activePage }: ContentAreaProps) {
return <KnowledgePage />;
case 'memory':
return <MemoryPage />;
case 'gateway':
return <GatewayPage />;
case 'files':
return <FilesPage />;
case 'settings':
return <SettingsPage />;
case 'skills':
return <SkillsPage />;
case 'documents':
return <DocumentsPage />;
default: {
const _exhaustive: never = activePage;
throw new Error(`Unhandled page key: ${_exhaustive}`);
+4 -15
View File
@@ -1,11 +1,9 @@
import { useState } from 'react';
import { Tooltip, Avatar, theme } from 'antd';
import { MessageSquare, BookOpen, Brain, Router, FolderOpen, User, Sparkles, ImagePlus } from 'lucide-react';
import { MessageSquare, BookOpen, Brain, FolderOpen, User, Sparkles, ImagePlus, FileText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useUIStore, useSettingsStore } from '@/stores';
import { useUIStore } from '@/stores';
import { useUserProfileStore } from '@/stores/userProfileStore';
import { getShortcutBinding, formatShortcutForDisplay } from '@/lib/shortcuts';
import type { ShortcutAction } from '@/lib/shortcuts';
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
import { UserProfileModal } from './UserProfileModal';
import type { PageKey } from '@/types';
@@ -14,9 +12,9 @@ const mainNavItems: { key: PageKey; icon: React.ReactNode; labelKey: string }[]
{ key: 'chat', icon: <MessageSquare size={18} />, labelKey: 'nav.chat' },
{ key: 'drawing', icon: <ImagePlus size={18} />, labelKey: 'nav.drawing' },
{ key: 'skills', icon: <Sparkles size={18} />, labelKey: 'nav.skills' },
{ key: 'documents', icon: <FileText size={18} />, labelKey: 'nav.documents' },
{ key: 'knowledge', icon: <BookOpen size={18} />, labelKey: 'nav.knowledge' },
{ key: 'memory', icon: <Brain size={18} />, labelKey: 'nav.memory' },
{ key: 'gateway', icon: <Router size={18} />, labelKey: 'nav.gateway' },
{ key: 'files', icon: <FolderOpen size={18} />, labelKey: 'nav.files' },
];
@@ -28,21 +26,12 @@ export function Sidebar() {
const profile = useUserProfileStore((s) => s.profile);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const resolvedAvatarSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue);
const settings = useSettingsStore((s) => s.settings);
const NAV_SHORTCUT_MAP: Partial<Record<PageKey, ShortcutAction>> = {
gateway: 'toggleGateway',
};
const renderNavButton = (item: { key: PageKey; icon: React.ReactNode; labelKey: string }) => {
const isActive = activePage === item.key;
const label = t(item.labelKey);
const action = NAV_SHORTCUT_MAP[item.key];
const title = action
? `${label} (${formatShortcutForDisplay(getShortcutBinding(settings, action))})`
: label;
return (
<Tooltip key={item.key} title={title} placement="right">
<Tooltip key={item.key} title={label} placement="right">
<button
onClick={() => setActivePage(item.key)}
className="flex items-center justify-center text-base transition-colors"