feat: add desktop document skills workspace
This commit is contained in:
@@ -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} />
|
||||
剪映自动剪辑
|
||||
{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>
|
||||
{installingJianying
|
||||
{installingSkillKey === preset.key
|
||||
? '正在安装/启用...'
|
||||
: jianyingSkill?.enabled
|
||||
? '已启用,生成草稿、字幕、配音、特效和导出'
|
||||
: jianyingSkill
|
||||
: status === 'enabled'
|
||||
? `已启用,${preset.description}`
|
||||
: status === 'installed'
|
||||
? '已安装,点击启用并写入提示'
|
||||
: '一键安装 luoluoluo22/jianying-editor-skill'}
|
||||
: `安装/启用 ${preset.name} Skill`}
|
||||
</small>
|
||||
</button>
|
||||
<button type="button"><Wrench size={16} /> 剧情短片 <small>自动生成故事大纲、分镜脚本并产出短片</small></button>
|
||||
<button type="button"><Wrench size={16} /> 电商套图 <small>生成风格统一的商品全套视觉素材</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}
|
||||
|
||||
@@ -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',
|
||||
...skillMenuPresets.map((preset) => {
|
||||
const status = getSkillPresetStatus(skills, preset);
|
||||
return {
|
||||
key: preset.key,
|
||||
icon: <Wrench size={14} />,
|
||||
label: (
|
||||
<div>
|
||||
<strong>剪映短片</strong>
|
||||
<strong>{preset.title}</strong>
|
||||
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{jianyingSkill?.enabled ? '已启用,可直接用 Agent 创建剪映草稿' : '安装/启用 jianying-editor Skill'}
|
||||
{status === 'enabled'
|
||||
? '已启用,可直接在 Agent 中使用'
|
||||
: status === 'installed'
|
||||
? '已安装,点击启用并插入提示词'
|
||||
: `安装/启用 ${preset.name} Skill`}
|
||||
</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}`);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"files": "Files",
|
||||
"settings": "Settings",
|
||||
"skills": "Skills",
|
||||
"documents": "Documents",
|
||||
"drawing": "Drawing"
|
||||
},
|
||||
"chat": {
|
||||
@@ -1314,6 +1315,7 @@
|
||||
"toggleInspector": "Toggle Inspector",
|
||||
"searchConversations": "Search Conversations",
|
||||
"goToSkills": "Go to Skills",
|
||||
"goToDocuments": "Go to Documents",
|
||||
"goToDrawing": "Go to Drawing"
|
||||
},
|
||||
"appLaunch": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"files": "文件",
|
||||
"settings": "设置",
|
||||
"skills": "技能",
|
||||
"documents": "生成文档",
|
||||
"drawing": "绘画"
|
||||
},
|
||||
"chat": {
|
||||
@@ -1314,6 +1315,7 @@
|
||||
"toggleInspector": "切换检查器",
|
||||
"searchConversations": "搜索对话",
|
||||
"goToSkills": "前往技能",
|
||||
"goToDocuments": "前往生成文档",
|
||||
"goToDrawing": "前往绘画"
|
||||
},
|
||||
"appLaunch": {
|
||||
|
||||
@@ -779,6 +779,108 @@ svg.lucide {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.documents-page {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 28px 36px;
|
||||
}
|
||||
|
||||
.documents-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.documents-hero h2.ant-typography {
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.documents-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.documents-card.ant-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.documents-card .ant-card-head-title > span,
|
||||
.documents-card .ant-card-head-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.documents-preset-list.ant-radio-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.documents-preset.ant-radio-button-wrapper {
|
||||
height: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border-inline-start-width: 1px;
|
||||
}
|
||||
|
||||
.documents-preset.ant-radio-button-wrapper::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.documents-preset strong,
|
||||
.documents-preset small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.documents-preset small {
|
||||
color: var(--ant-color-text-secondary);
|
||||
white-space: normal;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.documents-preset .ant-tag {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.documents-compose-card .ant-input {
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.documents-compose-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.documents-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.documents-hero,
|
||||
.documents-compose-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.documents-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ModelSelector: compact modal content padding */
|
||||
.model-selector-modal .ant-modal-container {
|
||||
padding: 6px !important;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Skill } from '@/types';
|
||||
|
||||
export type SkillPresetCategory = 'video' | 'document' | 'cad' | 'media';
|
||||
|
||||
export interface SkillPreset {
|
||||
key: string;
|
||||
name: string;
|
||||
source: string;
|
||||
title: string;
|
||||
description: string;
|
||||
promptPrefix: string;
|
||||
category: SkillPresetCategory;
|
||||
}
|
||||
|
||||
export const SKILL_PRESETS: SkillPreset[] = [
|
||||
{
|
||||
key: 'jianying',
|
||||
name: 'jianying-editor',
|
||||
source: 'luoluoluo22/jianying-editor-skill',
|
||||
title: '剪映短片',
|
||||
description: '创建剪映草稿、时间线、字幕和基础剪辑。',
|
||||
promptPrefix: '请使用 jianying-editor Skill 自动创建剪映草稿:',
|
||||
category: 'video',
|
||||
},
|
||||
{
|
||||
key: 'gorden-ppt',
|
||||
name: 'gorden-ppt-skill',
|
||||
source: 'GordenSun/GordenPPTSkill',
|
||||
title: 'Gorden PPT',
|
||||
description: '生成中文 PPT,保持版式并导出 PowerPoint 成品。',
|
||||
promptPrefix: '请使用 gorden-ppt-skill 生成一份中文 PPT:',
|
||||
category: 'document',
|
||||
},
|
||||
{
|
||||
key: 'knowledge-cat-ppt',
|
||||
name: 'knowledge-cat-ppt',
|
||||
source: 'gnipbao/knowledge-cat-ppt-skill',
|
||||
title: 'Knowledge Cat PPT',
|
||||
description: '把知识库、文档、笔记和研究材料整理成结构化 PPT。',
|
||||
promptPrefix: '请使用 knowledge-cat-ppt Skill 根据资料生成一份结构化 PPT:',
|
||||
category: 'document',
|
||||
},
|
||||
{
|
||||
key: 'seedance-prompt',
|
||||
name: 'seedance-prompt-en',
|
||||
source: 'MapleShaw/seedance2.0-prompt-skill',
|
||||
title: 'Seedance 提示词',
|
||||
description: '为 Seedance 2.0 生成镜头、运动和视频提示词。',
|
||||
promptPrefix: '请使用 seedance-prompt-en Skill 优化下面的视频生成提示词:',
|
||||
category: 'video',
|
||||
},
|
||||
{
|
||||
key: 'seedance-video',
|
||||
name: 'seedance',
|
||||
source: 'dexhunter/seedance2-skill',
|
||||
title: 'Seedance 视频',
|
||||
description: '生成 Seedance 2 视频任务方案与参数。',
|
||||
promptPrefix: '请使用 seedance Skill 生成视频创作方案:',
|
||||
category: 'video',
|
||||
},
|
||||
{
|
||||
key: 'hyperframes',
|
||||
name: 'hyperframes',
|
||||
source: 'heygen-com/hyperframes',
|
||||
title: 'HyperFrames',
|
||||
description: '用 HTML/CSS/JS 动画确定性渲染产品视频或解释视频。',
|
||||
promptPrefix: '请使用 hyperframes Skill 生成一段可渲染成 MP4 的动画视频:',
|
||||
category: 'video',
|
||||
},
|
||||
{
|
||||
key: 'remotion-video',
|
||||
name: 'remotion-video',
|
||||
source: 'remotion-dev/skills',
|
||||
title: 'Remotion 视频',
|
||||
description: '用 React/Remotion 制作模板化、可批量渲染的视频。',
|
||||
promptPrefix: '请使用 remotion-video Skill 生成 Remotion 视频方案:',
|
||||
category: 'video',
|
||||
},
|
||||
{
|
||||
key: 'video-use',
|
||||
name: 'video-use',
|
||||
source: 'browser-use/video-use',
|
||||
title: 'Video-Use 混剪',
|
||||
description: '根据素材自动剪辑、字幕、调色和合成成片。',
|
||||
promptPrefix: '请使用 video-use Skill 将素材混剪成最终视频:',
|
||||
category: 'video',
|
||||
},
|
||||
{
|
||||
key: 'ffmpeg-media',
|
||||
name: 'ffmpeg-media',
|
||||
source: 'fabriqaai/ffmpeg-analyse-video-skill',
|
||||
title: 'FFmpeg 处理',
|
||||
description: '分析、压缩、裁剪、转码和抽帧音视频素材。',
|
||||
promptPrefix: '请使用 ffmpeg-media Skill 处理下面的音视频任务:',
|
||||
category: 'media',
|
||||
},
|
||||
{
|
||||
key: 'manim-animation',
|
||||
name: 'manim-animation',
|
||||
source: 'adithya-s-k/manim_skill',
|
||||
title: 'Manim 动画',
|
||||
description: '生成数学、知识类讲解动画脚本与渲染方案。',
|
||||
promptPrefix: '请使用 manim-animation Skill 制作一段知识讲解动画:',
|
||||
category: 'video',
|
||||
},
|
||||
{
|
||||
key: 'dwg-mcp-server',
|
||||
name: 'dwg-mcp-server',
|
||||
source: 'dimitrovakulenko/dwg-mcp-server',
|
||||
title: 'DWG 图纸分析',
|
||||
description: '读取 DWG 图纸,分析图层、块、实体和属性。',
|
||||
promptPrefix: '请使用 dwg-mcp-server Skill 分析这份 CAD/DWG 图纸:',
|
||||
category: 'cad',
|
||||
},
|
||||
{
|
||||
key: 'cad-ai-agent',
|
||||
name: 'cad-ai-agent',
|
||||
source: 'jeremylongshore/cad-ai-agent',
|
||||
title: 'CAD AI Agent',
|
||||
description: '做 CAD/DXF 图纸摘要、检查、工程量和安全编辑计划。',
|
||||
promptPrefix: '请使用 cad-ai-agent Skill 处理下面的 CAD 任务:',
|
||||
category: 'cad',
|
||||
},
|
||||
];
|
||||
|
||||
export function findSkillByPreset(skills: Skill[], preset: SkillPreset): Skill | null {
|
||||
return skills.find((skill) => skill.name === preset.name) ?? null;
|
||||
}
|
||||
|
||||
export function getSkillPresetStatus(skills: Skill[], preset: SkillPreset): 'enabled' | 'installed' | 'missing' {
|
||||
const skill = findSkillByPreset(skills, preset);
|
||||
if (!skill) return 'missing';
|
||||
return skill.enabled ? 'enabled' : 'installed';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { App, Button, Card, Input, Radio, Space, Tag, Typography, theme } from 'antd';
|
||||
import { BookOpen, FileText, Presentation, Sparkles, Wrench } 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 { useUIStore } from '@/stores/uiStore';
|
||||
import type { Model } from '@/types';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
function isUsableChatModel(model: Model): boolean {
|
||||
return model.enabled && (model.model_type === 'Chat' || model.capabilities.includes('TextChat'));
|
||||
}
|
||||
|
||||
function statusLabel(status: 'enabled' | 'installed' | 'missing'): string {
|
||||
if (status === 'enabled') return '已启用';
|
||||
if (status === 'installed') return '已安装';
|
||||
return '待安装';
|
||||
}
|
||||
|
||||
export function DocumentsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
const fetchProviders = useProviderStore((s) => s.fetchProviders);
|
||||
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 createConversation = useConversationStore((s) => s.createConversation);
|
||||
const updateConversation = useConversationStore((s) => s.updateConversation);
|
||||
const sendAgentMessage = useConversationStore((s) => s.sendAgentMessage);
|
||||
const setActivePage = useUIStore((s) => s.setActivePage);
|
||||
const [selectedPresetKey, setSelectedPresetKey] = useState('gorden-ppt');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [installingKey, setInstallingKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (providers.length === 0) void fetchProviders().catch((error) => message.error(String(error)));
|
||||
}, [fetchProviders, message, providers.length]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
const documentPresets = useMemo(() => (
|
||||
SKILL_PRESETS.filter((preset) => preset.category === 'document')
|
||||
), []);
|
||||
|
||||
const selectedPreset = useMemo(() => (
|
||||
documentPresets.find((preset) => preset.key === selectedPresetKey) ?? documentPresets[0]
|
||||
), [documentPresets, selectedPresetKey]);
|
||||
|
||||
const defaultChatModel = useMemo(() => {
|
||||
for (const provider of providers) {
|
||||
if (!provider.enabled) continue;
|
||||
const model = provider.models.find(isUsableChatModel);
|
||||
if (model) return { providerId: provider.id, modelId: model.model_id, providerName: provider.name, modelName: model.name || model.model_id };
|
||||
}
|
||||
return null;
|
||||
}, [providers]);
|
||||
|
||||
const ensureSkillReady = useCallback(async (preset: SkillPreset) => {
|
||||
const existing = findSkillByPreset(useSkillStore.getState().skills, preset);
|
||||
if (!existing) {
|
||||
setInstallingKey(preset.key);
|
||||
await installSkill(preset.source);
|
||||
await loadSkills();
|
||||
message.success(`${preset.title} Skill 已安装`);
|
||||
return;
|
||||
}
|
||||
if (!existing.enabled) {
|
||||
setInstallingKey(preset.key);
|
||||
await toggleSkill(preset.name, true);
|
||||
await loadSkills();
|
||||
message.success(`${preset.title} Skill 已启用`);
|
||||
}
|
||||
}, [installSkill, loadSkills, message, toggleSkill]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = prompt.trim();
|
||||
if (!text) {
|
||||
message.warning('先输入要生成的文档主题、资料或大纲');
|
||||
return;
|
||||
}
|
||||
if (!selectedPreset || submitting) return;
|
||||
if (!defaultChatModel) {
|
||||
message.warning('先在服务商管理里启用一个可对话模型');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await ensureSkillReady(selectedPreset);
|
||||
const conversation = await createConversation(
|
||||
`${selectedPreset.title} · ${text.slice(0, 18)}`,
|
||||
defaultChatModel.modelId,
|
||||
defaultChatModel.providerId,
|
||||
);
|
||||
await updateConversation(conversation.id, {
|
||||
mode: 'agent',
|
||||
provider_id: defaultChatModel.providerId,
|
||||
model_id: defaultChatModel.modelId,
|
||||
});
|
||||
await invoke('agent_update_session', {
|
||||
conversationId: conversation.id,
|
||||
permissionMode: 'accept_edits',
|
||||
});
|
||||
await sendAgentMessage(`${selectedPreset.promptPrefix}${text}`);
|
||||
setPrompt('');
|
||||
setActivePage('chat');
|
||||
} catch (error) {
|
||||
message.error(`生成文档任务启动失败:${String(error)}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setInstallingKey(null);
|
||||
}
|
||||
}, [
|
||||
createConversation,
|
||||
defaultChatModel,
|
||||
ensureSkillReady,
|
||||
message,
|
||||
prompt,
|
||||
selectedPreset,
|
||||
sendAgentMessage,
|
||||
setActivePage,
|
||||
submitting,
|
||||
updateConversation,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="documents-page" style={{ backgroundColor: token.colorBgElevated }}>
|
||||
<section className="documents-hero">
|
||||
<div>
|
||||
<Tag color="green" icon={<Sparkles size={12} />}>BoosAPI Skills</Tag>
|
||||
<Title level={2}>生成文档</Title>
|
||||
<Text type="secondary">
|
||||
用 Gorden PPT、Knowledge Cat PPT 等 Skill 把主题、资料和大纲转成可继续编辑的 PPT / 文档任务。
|
||||
</Text>
|
||||
</div>
|
||||
<Button icon={<BookOpen size={16} />} onClick={() => setActivePage('skills')}>
|
||||
管理技能
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div className="documents-layout">
|
||||
<Card className="documents-card" title={<span><Presentation size={16} /> 文档类型</span>}>
|
||||
<Radio.Group
|
||||
value={selectedPresetKey}
|
||||
onChange={(event) => setSelectedPresetKey(event.target.value)}
|
||||
className="documents-preset-list"
|
||||
>
|
||||
{documentPresets.map((preset) => {
|
||||
const status = getSkillPresetStatus(skills, preset);
|
||||
return (
|
||||
<Radio.Button key={preset.key} value={preset.key} className="documents-preset">
|
||||
<strong>{preset.title}</strong>
|
||||
<small>{preset.description}</small>
|
||||
<Tag color={status === 'enabled' ? 'green' : status === 'installed' ? 'blue' : 'default'}>
|
||||
{statusLabel(status)}
|
||||
</Tag>
|
||||
</Radio.Button>
|
||||
);
|
||||
})}
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
<Card className="documents-card documents-compose-card" title={<span><FileText size={16} /> 生成内容</span>}>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
autoSize={{ minRows: 8, maxRows: 14 }}
|
||||
placeholder="例如:根据 BossAI 的产品介绍生成一份 12 页融资路演 PPT,包含市场、产品、技术架构、商业模式和路线图。"
|
||||
/>
|
||||
<div className="documents-compose-footer">
|
||||
<Space size={8} wrap>
|
||||
<Tag>{selectedPreset?.name}</Tag>
|
||||
<Tag>{defaultChatModel ? `${defaultChatModel.providerName} · ${defaultChatModel.modelName}` : '未选择对话模型'}</Tag>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Wrench size={16} />}
|
||||
loading={submitting || Boolean(installingKey)}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
生成文档
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -659,9 +659,9 @@ export interface RealtimeConfig {
|
||||
export type PageKey =
|
||||
| 'chat'
|
||||
| 'drawing'
|
||||
| 'documents'
|
||||
| 'knowledge'
|
||||
| 'memory'
|
||||
| 'gateway'
|
||||
| 'files'
|
||||
| 'settings'
|
||||
| 'skills';
|
||||
|
||||
Reference in New Issue
Block a user