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"
+2
View File
@@ -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": {
+2
View File
@@ -11,6 +11,7 @@
"files": "文件",
"settings": "设置",
"skills": "技能",
"documents": "生成文档",
"drawing": "绘画"
},
"chat": {
@@ -1314,6 +1315,7 @@
"toggleInspector": "切换检查器",
"searchConversations": "搜索对话",
"goToSkills": "前往技能",
"goToDocuments": "前往生成文档",
"goToDrawing": "前往绘画"
},
"appLaunch": {
+102
View File
@@ -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;
+135
View File
@@ -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';
}
+200
View File
@@ -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>
);
}
+1 -1
View File
@@ -659,9 +659,9 @@ export interface RealtimeConfig {
export type PageKey =
| 'chat'
| 'drawing'
| 'documents'
| 'knowledge'
| 'memory'
| 'gateway'
| 'files'
| 'settings'
| 'skills';