feat: add desktop agent workbench
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { PageKey } from '@/types';
|
||||
import { AgentWorkbenchPage } from '@/pages/AgentWorkbenchPage';
|
||||
import { ChatPage } from '@/pages/ChatPage';
|
||||
import { DrawingPage } from '@/pages/DrawingPage';
|
||||
import { KnowledgePage } from '@/pages/KnowledgePage';
|
||||
@@ -16,6 +17,8 @@ export function ContentArea({ activePage }: ContentAreaProps) {
|
||||
switch (activePage) {
|
||||
case 'chat':
|
||||
return <ChatPage />;
|
||||
case 'agent-workbench':
|
||||
return <AgentWorkbenchPage />;
|
||||
case 'drawing':
|
||||
return <DrawingPage />;
|
||||
case 'knowledge':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, Router, FolderOpen, User, Sparkles, ImagePlus, WandSparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore, useSettingsStore } from '@/stores';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore';
|
||||
@@ -12,6 +12,7 @@ import type { PageKey } from '@/types';
|
||||
|
||||
const mainNavItems: { key: PageKey; icon: React.ReactNode; labelKey: string }[] = [
|
||||
{ key: 'chat', icon: <MessageSquare size={18} />, labelKey: 'nav.chat' },
|
||||
{ key: 'agent-workbench', icon: <WandSparkles size={18} />, labelKey: 'nav.agentWorkbench' },
|
||||
{ key: 'drawing', icon: <ImagePlus size={18} />, labelKey: 'nav.drawing' },
|
||||
{ key: 'skills', icon: <Sparkles size={18} />, labelKey: 'nav.skills' },
|
||||
{ key: 'knowledge', icon: <BookOpen size={18} />, labelKey: 'nav.knowledge' },
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "المحادثة",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "قاعدة المعرفة",
|
||||
"memory": "الذاكرة",
|
||||
"gateway": "البوابة",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "Chat",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "Wissen",
|
||||
"memory": "Gedächtnis",
|
||||
"gateway": "Gateway",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "Chat",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "Knowledge",
|
||||
"memory": "Memory",
|
||||
"gateway": "Gateway",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "Chat",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "Base de conocimientos",
|
||||
"memory": "Memoria",
|
||||
"gateway": "Pasarela",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "Chat",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "Connaissances",
|
||||
"memory": "Mémoire",
|
||||
"gateway": "Passerelle",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "चैट",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "ज्ञान आधार",
|
||||
"memory": "स्मृति",
|
||||
"gateway": "गेटवे",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "チャット",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "ナレッジ",
|
||||
"memory": "メモリ",
|
||||
"gateway": "ゲートウェイ",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "채팅",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "지식",
|
||||
"memory": "메모리",
|
||||
"gateway": "게이트웨이",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "Чат",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "База знаний",
|
||||
"memory": "Память",
|
||||
"gateway": "Шлюз",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "对话",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "知识库",
|
||||
"memory": "记忆",
|
||||
"gateway": "网关",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"chat": "對話",
|
||||
"agentWorkbench": "Agent",
|
||||
"knowledge": "知識庫",
|
||||
"memory": "記憶",
|
||||
"gateway": "閘道",
|
||||
|
||||
@@ -29,6 +29,365 @@ body {
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.agent-workbench-desktop {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.agent-workbench-desktop-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(247, 249, 251, 0.96), rgba(248, 250, 252, 0.98)),
|
||||
radial-gradient(circle at 18% 18%, rgba(22, 119, 255, 0.08), transparent 28%),
|
||||
radial-gradient(circle at 82% 78%, rgba(20, 184, 166, 0.08), transparent 30%);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .agent-workbench-desktop-bg {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(12, 18, 24, 0.98), rgba(14, 18, 22, 0.98)),
|
||||
radial-gradient(circle at 18% 18%, rgba(22, 119, 255, 0.12), transparent 28%),
|
||||
radial-gradient(circle at 82% 78%, rgba(20, 184, 166, 0.1), transparent 30%);
|
||||
}
|
||||
|
||||
.agent-workbench-desktop-hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 54px;
|
||||
height: 100%;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.agent-workbench-desktop-hero h1 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
font-weight: 780;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.agent-workbench-desktop-composer {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(118px, auto) auto auto;
|
||||
gap: 10px 18px;
|
||||
width: min(78vw, 1280px);
|
||||
min-height: 232px;
|
||||
padding: 24px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 26px;
|
||||
background: color-mix(in srgb, var(--color-bg-elevated) 94%, transparent);
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.agent-workbench-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-workbench-upload {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 72px;
|
||||
height: 92px;
|
||||
margin-top: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 92%, transparent);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-fill-alter) 80%, var(--color-bg-elevated));
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transform: rotate(-8deg);
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
.agent-workbench-upload:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
transform: rotate(-5deg) translateY(-1px);
|
||||
}
|
||||
|
||||
.agent-workbench-prompt {
|
||||
align-self: stretch;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--color-text) !important;
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.agent-workbench-prompt textarea,
|
||||
.agent-workbench-prompt.ant-input {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-workbench-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.agent-workbench-attachments .ant-tag {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 260px;
|
||||
margin: 0;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.agent-workbench-attachments .ant-tag span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.agent-workbench-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.agent-workbench-tool-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-workbench-pill {
|
||||
min-height: 44px;
|
||||
border-radius: 12px !important;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.agent-workbench-pill.active {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-elevated));
|
||||
}
|
||||
|
||||
.agent-workbench-pill .ant-btn-icon,
|
||||
.agent-workbench-pill > span:not(.ant-btn-icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-workbench-pill .ant-switch {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.agent-workbench-pill.square {
|
||||
width: 44px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.agent-workbench-model-select {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.agent-workbench-model-select .ant-select-selector,
|
||||
.agent-workbench-model-input {
|
||||
min-height: 44px;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.agent-workbench-model-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.agent-workbench-submit {
|
||||
width: 92px;
|
||||
min-height: 44px;
|
||||
border-radius: 12px !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.agent-workbench-result {
|
||||
width: min(78vw, 920px);
|
||||
padding: 18px 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 20%, var(--border-color));
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-elevated));
|
||||
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.agent-workbench-result strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.agent-workbench-result p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.agent-workbench-mode-menu .ant-dropdown-menu {
|
||||
width: 300px;
|
||||
padding: 8px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.agent-workbench-mode-item {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 18px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.agent-workbench-mode-item > span {
|
||||
display: inline-flex;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.agent-workbench-mode-item div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-workbench-mode-item strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.agent-workbench-mode-item small {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-workbench-mode-item em {
|
||||
color: var(--color-primary);
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.agent-workbench-popover,
|
||||
.agent-workbench-subject-popover {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px;
|
||||
background: var(--color-bg-elevated);
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.agent-workbench-popover {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 420px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.agent-workbench-popover button {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-workbench-popover button:hover {
|
||||
background: var(--color-fill-alter);
|
||||
}
|
||||
|
||||
.agent-workbench-popover small {
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.agent-workbench-subject-popover {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: 310px;
|
||||
min-height: 300px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.agent-workbench-subject-popover > span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-workbench-subject-popover > div {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 16px;
|
||||
min-height: 220px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-workbench-subject-popover button {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--color-fill-alter);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.agent-workbench-desktop-composer,
|
||||
.agent-workbench-result {
|
||||
width: min(92vw, 980px);
|
||||
}
|
||||
|
||||
.agent-workbench-desktop-composer {
|
||||
grid-template-columns: 74px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.agent-workbench-upload {
|
||||
width: 58px;
|
||||
height: 78px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.agent-workbench-desktop-hero {
|
||||
justify-content: stretch;
|
||||
padding: 28px 14px;
|
||||
}
|
||||
|
||||
.agent-workbench-desktop-composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agent-workbench-upload {
|
||||
width: 54px;
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
.agent-workbench-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-workbench-submit {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove antd focus outline (green border) globally */
|
||||
*:focus-visible {
|
||||
outline: none !important;
|
||||
|
||||
@@ -1584,7 +1584,7 @@ export async function handleCommand<T>(cmd: string, args?: Record<string, unknow
|
||||
|
||||
// ── Skills ────────────────────────────────────────────────────────
|
||||
case 'list_skills':
|
||||
return [] as T;
|
||||
return getStore<any[]>('skills', []) as T;
|
||||
|
||||
case 'get_skill':
|
||||
return {
|
||||
@@ -1602,11 +1602,36 @@ export async function handleCommand<T>(cmd: string, args?: Record<string, unknow
|
||||
manifest: null,
|
||||
} as T;
|
||||
|
||||
case 'toggle_skill':
|
||||
case 'toggle_skill': {
|
||||
const { name, enabled } = args as any;
|
||||
const skills = getStore<any[]>('skills', []);
|
||||
setStore('skills', skills.map((skill: any) => (
|
||||
skill.name === name ? { ...skill, enabled } : skill
|
||||
)));
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
case 'install_skill':
|
||||
return ((args as any)?.source || 'installed-skill') as T;
|
||||
case 'install_skill': {
|
||||
const source = (args as any)?.source || 'installed-skill';
|
||||
const name = source.includes('jianying-editor-skill') ? 'jianying-editor' : String(source).split('/').pop();
|
||||
const skills = getStore<any[]>('skills', []);
|
||||
const nextSkill = {
|
||||
name,
|
||||
description: name === 'jianying-editor'
|
||||
? '剪映 AI 自动化剪辑 Skill,支持草稿生成、素材导入、字幕、配音、特效和导出。'
|
||||
: 'Installed skill',
|
||||
source: 'boosapi',
|
||||
sourcePath: `/mock/skills/${name}`,
|
||||
enabled: true,
|
||||
hasUpdate: false,
|
||||
userInvocable: true,
|
||||
};
|
||||
setStore('skills', [
|
||||
nextSkill,
|
||||
...skills.filter((skill: any) => skill.name !== name),
|
||||
]);
|
||||
return source as T;
|
||||
}
|
||||
|
||||
case 'uninstall_skill':
|
||||
return undefined as T;
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
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 { useConversationStore, useProviderStore, useSkillStore, useUIStore } 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' },
|
||||
];
|
||||
|
||||
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`;
|
||||
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 AgentWorkbenchPage() {
|
||||
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 setActivePage = useUIStore((s) => s.setActivePage);
|
||||
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 [installingJianying, setInstallingJianying] = useState(false);
|
||||
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 jianyingSkill = useMemo(() => (
|
||||
skills.find((item) => item.name === JIANYING_SKILL_NAME) ?? null
|
||||
), [skills]);
|
||||
|
||||
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 applyJianyingSkill = useCallback(async () => {
|
||||
if (installingJianying) return;
|
||||
setInstallingJianying(true);
|
||||
try {
|
||||
if (!jianyingSkill) {
|
||||
await installSkill(JIANYING_SKILL_SOURCE);
|
||||
message.success('剪映 Skill 已安装');
|
||||
} else if (!jianyingSkill.enabled) {
|
||||
await toggleSkill(JIANYING_SKILL_NAME, true);
|
||||
message.success('剪映 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}`;
|
||||
});
|
||||
setSkillOpen(false);
|
||||
} catch (error) {
|
||||
message.error(`剪映 Skill 集成失败:${String(error)}`);
|
||||
} finally {
|
||||
setInstallingJianying(false);
|
||||
}
|
||||
}, [installSkill, installingJianying, jianyingSkill, 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([]);
|
||||
setActivePage('chat');
|
||||
} 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,
|
||||
setActivePage,
|
||||
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} />} />
|
||||
<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>
|
||||
<button type="button"><Plus size={16} /> 用 Agent 创建技能</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button className="agent-workbench-pill" 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>
|
||||
);
|
||||
}
|
||||
@@ -656,7 +656,16 @@ export interface RealtimeConfig {
|
||||
}
|
||||
|
||||
// === UI State ===
|
||||
export type PageKey = 'chat' | 'drawing' | 'knowledge' | 'memory' | 'gateway' | 'files' | 'settings' | 'skills';
|
||||
export type PageKey =
|
||||
| 'chat'
|
||||
| 'agent-workbench'
|
||||
| 'drawing'
|
||||
| 'knowledge'
|
||||
| 'memory'
|
||||
| 'gateway'
|
||||
| 'files'
|
||||
| 'settings'
|
||||
| 'skills';
|
||||
|
||||
// === Drawing ===
|
||||
export type DrawingModelId = 'gpt-image-2' | 'gpt-image-1.5' | 'gpt-image-1' | 'gpt-image-1-mini';
|
||||
|
||||
Reference in New Issue
Block a user