Commit fa5f1286 authored by AI-甘富林's avatar AI-甘富林

refactor(ui): 简化App.tsx布局并新增knowledge视图模式

- 新增knowledge视图模式类型定义
- 移除根容器和侧边栏的硬编码Tailwind样式
- 移除窗口控制按钮组件,简化整体布局结构
- 为知识管理功能添加导航图标和基础支持
Co-Authored-By: 's avatarClaude Opus 4.7 <noreply@anthropic.com>
parent 647e5838
......@@ -29,7 +29,7 @@ import {
FIXED_EXPERT_MODEL_ENDPOINTS
} from "@qjclaw/shared-types";
type ViewMode = "chat" | "experts" | "plugins" | "settings";
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge";
type Tone = "positive" | "warning" | "info";
type MessageStreamState = "streaming" | "error";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
......@@ -510,7 +510,7 @@ function getIntentSuggestionIcon(platform?: string): ReactNode {
return <BrowserExpertIcon />;
}
function NavIcon({ kind }: { kind: "chat" | "experts" | "plugins" | "settings" }) {
function NavIcon({ kind }: { kind: "chat" | "experts" | "plugins" | "settings" | "knowledge" }) {
switch (kind) {
case "chat":
return (
......@@ -530,6 +530,13 @@ function NavIcon({ kind }: { kind: "chat" | "experts" | "plugins" | "settings" }
<path d="M9.25 4.75h2.5v3h3V4.5a1.75 1.75 0 1 1 3.5 0v3.4a2.1 2.1 0 0 1-2.1 2.1h-2.4v2.25H16a2 2 0 0 1 2 2V17a2.25 2.25 0 0 1-2.25 2.25H13.5v-2.5h-3v2.5H8.25A2.25 2.25 0 0 1 6 17v-2.75a2 2 0 0 1 2-2h2.25V10H7.9a2.1 2.1 0 0 1-2.1-2.1V4.5a1.75 1.75 0 1 1 3.5 0v3.25h3v-3Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
</svg>
);
case "knowledge":
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</svg>
);
case "settings":
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
......@@ -558,11 +565,13 @@ const ui = {
skills: "\u80fd\u529b",
experts: "\u4e13\u5bb6",
plugins: "\u63d2\u4ef6",
knowledge: "知识库",
knowledgePageDesc: "上传和管理您的企业知识文档",
settings: "\u8bbe\u7f6e",
bound: "\u5df2\u7ed1\u5b9a",
unbound: "\u672a\u7ed1\u5b9a",
defaultChat: "千匠问天",
bindTitle: "绑定员工密钥",
bindTitle: "绑定龙虾密钥",
bindDesc: "你好,我是千匠问天。可以先帮你整理 Excel、汇总资料、检索公开信息、拆解执行任务;如果你要做小红书或抖音内容,也可以切到对应专家继续处理。",
apiKey: "\u5458\u5de5\u5bc6\u94a5",
apiKeyPlaceholder: "\u8bf7\u8f93\u5165 OpenClaw employee api_key",
......@@ -655,6 +664,39 @@ const ui = {
none: "\u65e0"
} as const;
const CATEGORY_CONFIG = [
{
id: 'content',
name: '内容营销',
icon: '📝',
color: 'rgba(109, 93, 252, 0.2)',
hoverColor: 'rgba(109, 93, 252, 0.3)'
},
{
id: 'acquisition',
name: '精准获客',
icon: '🎯',
color: 'rgba(141, 156, 255, 0.2)',
hoverColor: 'rgba(141, 156, 255, 0.3)'
},
{
id: 'sales',
name: '销售冠军',
icon: '🏆',
color: 'rgba(86, 205, 255, 0.2)',
hoverColor: 'rgba(86, 205, 255, 0.3)'
},
{
id: 'other',
name: '其他专家',
icon: '📁',
color: 'rgba(168, 157, 255, 0.2)',
hoverColor: 'rgba(168, 157, 255, 0.3)'
}
] as const;
const startupCurtainCopy = {
brandTitle: "\u5343\u5320\u00b7\u95ee\u5929",
brandTagline: "START YOUR IDEAS",
......@@ -672,13 +714,13 @@ const startupCurtainCopy = {
const homeChatCopy = {
title: "首页对话",
microcopy: "从一个实用小任务开始:整理 Excel、汇总资料、检索信息,或把需求拆成执行清单。",
emptyTitle: "先选一个能立刻开始的小任务。",
emptyDesc: "首页适合先做 4 类事:Excel 整理、资料汇总、公开信息检索、需求拆解;明确要做小红书或抖音内容时,再切到对应专家继续处理。",
emptyTitle: " 先选一个能立刻开始的小任务",
// emptyDesc: "首页适合做 4 类事:Excel 整理、资料汇总、公开信息检索、需求拆解;明确要做小红书或抖音内容时,再切到对应专家继续处理",
prompts: [
"帮我把这份 Excel 台账整理成可汇报的结构。",
"根据这堆资料,提炼 5 条关键信息给我。",
"帮我查这个主题需要关注哪些公开信息。",
"把这个需求拆成今天就能执行的清单。"
"我想做海报内容,请帮我先整理主题、卖点层级、标题和版面文案",
"我想做 GEO 方向内容,请先帮我明确目标、策略框架和执行重点",
"我想做平台精准线索获取,请帮我梳理目标人群、线索标准、触达话术和转化路径",
"我想以销售冠军的方式推进成交,请帮我梳理目标客户、沟通话术、异议处理和转化动作"
]
} as const;
......@@ -1388,16 +1430,6 @@ function ThumbIcon({ direction }: { direction: MessageReaction }) {
);
}
function MoreIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<circle cx="6.5" cy="12" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="17.5" cy="12" r="1.5" />
</svg>
);
}
function formatCodeLanguageLabel(language: string): string {
const normalized = language.trim().toLowerCase();
if (!normalized) {
......@@ -1833,7 +1865,7 @@ function buildDouyinVideoStatusCard(message: UiChatMessage, expertKey: ReturnTyp
const [meta, eta, hint] = detail.split(" | ").map((item) => item.trim()).filter(Boolean);
const metaParts = [meta, eta].filter(Boolean);
return {
title: title || "视频正在生成中",
title: title || "视频正在生成中...",
meta: metaParts.length ? metaParts.join(" · ") : undefined,
hint: hint || "已进入长任务生成阶段,客户端会持续刷新状态。"
};
......@@ -1846,10 +1878,9 @@ function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent
greeting: "把产品、场景和目标人群说清楚,我先给你一版能直接开工的小红书任务。",
summary: "适合先做选题判断、笔记结构、标题草案、配图思路和发布时间建议。",
prompts: [
"帮我做一篇周末探店笔记,先出标题、封面字和 6 段正文结构。",
"把这款护肤品卖点整理成一篇日常分享风的小红书笔记。",
"给通勤女生做一篇春季穿搭笔记,先列 4 个可拍内容点。",
"帮我安排一篇家居好物笔记的发布时间和评论区引导话术。"
"帮我做一篇推荐平价火锅的笔记",
"做一篇制作抹茶奶酪欧包教程的爆文。",
"给通勤女生做一篇春季穿搭笔记。"
]
};
case "douyin":
......@@ -1876,12 +1907,11 @@ function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent
};
default:
return {
greeting: "说清你的目标,我会先帮你拆成可执行步骤",
summary: "适合梳理需求、生成方案、组织信息和推进执行",
greeting: "说清你的目标,我会先帮你拆成可执行步骤",
summary: "适合梳理需求、生成方案、组织信息和推进执行",
prompts: [
"先帮我梳理这个任务的目标、输入和输出。",
"把我的需求拆成 3 个最先执行的步骤。",
"根据当前专家能力,给我一个最实用的处理方案。"
"你好啊",
"请做一个自我介绍,给出一个你的能力清单"
]
};
}
......@@ -1894,7 +1924,7 @@ function getExpertGuideContent(project: ExpertProject | undefined): ExpertGuideC
}
return {
greeting: "先把这条抖音视频的条件说清楚,我会先帮你整理需求并给出预览。",
greeting: " 先把这条抖音视频的条件说清楚,我会先帮你整理需求并给出预览。",
summary: "适合抖音视频需求补全、文案与分镜预览、数字人口播和纯画面路线判断。",
intro: "最好一次说明主题、给谁看、想达到什么目标、风格、时长、做数字人还是纯画面,以及现在有没有图片或音频。",
requirementChecklist: [
......@@ -1908,28 +1938,26 @@ function getExpertGuideContent(project: ExpertProject | undefined): ExpertGuideC
],
routeOptions: [
{
title: "有人出镜讲解",
title: " 有人出镜讲解",
detail: "适合数字人口播、知识讲解、产品说明。继续生成前通常要确认人物图片,以及现成音频或男声/女声配音。",
accent: "host"
},
{
title: "纯画面展示",
title: " 纯画面展示",
detail: "适合氛围片、空镜、场景展示、画面加旁白。先把视频秒数说清楚,再出文案和分镜预览。",
accent: "visual"
}
],
workflowSteps: [
"先生成文案和分镜预览,不直接开跑视频。",
"你确认预览后,再继续生成数字人或纯画面视频。",
"如果当前路线缺素材,再按需要补图片、音频或配音要求。"
" 先生成文案和分镜预览,不直接开跑视频。",
" 你确认预览后,再继续生成数字人或纯画面视频。",
" 如果当前路线缺素材,再按需要补图片、音频或配音要求。"
],
continueHint: "看完预览后,你可以直接回复:继续生成;如果要改,直接回复:修改第几镜 + 你的修改意见。",
// continueHint: "看完预览后,你可以直接回复:继续生成;如果要改,直接回复:修改第几镜 + 你的修改意见。",
placeholder: "例如:做一个讲天气预报的抖音视频,给职场人看,目标是科普,男声数字人口播,10 秒,氛围感,我会上传人物照片,先给我文案和分镜预览。",
prompts: [
"做一个讲天气预报的抖音视频,给职场人看,目标是科普,男声数字人口播,10 秒,氛围感,我会上传人物照片,先给我文案和分镜预览。",
"做一个咖啡店活动抖音视频,给附近上班族看,目标是到店引流,纯画面展示,15 秒,真实手机拍摄感,先只看文案和分镜。",
"做一个护肤品种草短视频,给 25-35 岁女性看,目标是种草转化,纯画面加旁白,20 秒,精致广告感,先出预览。",
"我有人物照片,没有现成音频,想做数字人口播,主题是新品发布,给职场人看,先帮我整理需求并出预览。"
"做一个海边视频不要出现人物,给附近上班族看,目标是到店引流,纯画面展示,15 秒,真实手机拍摄感",
"做一个护肤品种草短视频,给 25-35 岁女性看,目标是种草转化,纯画面加旁白,10 秒,精致广告感"
]
};
}
......@@ -2024,10 +2052,10 @@ export default function App() {
const [homeIntentDecisionPending, setHomeIntentDecisionPending] = useState(false);
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>({});
const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({});
const [messageReactions, setMessageReactions] = useState<Record<string, MessageReaction | undefined>>({});
const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({});
const [sessionActionMenuId, setSessionActionMenuId] = useState("");
const [skillMenuOpen, setSkillMenuOpen] = useState(false);
const [isComposerDragOver, setIsComposerDragOver] = useState(false);
const [copiedToken, setCopiedToken] = useState("");
......@@ -2039,6 +2067,16 @@ export default function App() {
const startupWarmupRequestedRef = useRef(false);
const lastLoadedWorkspacePathRef = useRef<string | null>(null);
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null);
const categoryPopupRef = useRef<HTMLDivElement | null>(null);
// 切换分类展开状态
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => ({
...prev,
[categoryId]: !prev[categoryId]
}));
};
const minimizeWindow = () => void desktopApi.window.minimize();
const maximizeWindow = () => void desktopApi.window.maximize();
const catalogSkills = workspace?.skills ?? [];
......@@ -2189,8 +2227,8 @@ export default function App() {
})));
const workspaceStatusTone = getWorkspaceStatusTone(chatLaunchState, isBound);
const workspaceStatusLabel = getWorkspaceStatusLabel(chatLaunchState, isBound);
const pageTitle = viewMode === "plugins" ? ui.plugins : ui.settings;
const pageDesc = viewMode === "plugins" ? ui.pluginsPageDesc : ui.settingsDesc;
const pageTitle = viewMode === "plugins" ? ui.plugins : viewMode === "knowledge" ? ui.knowledge : ui.settings;
const pageDesc = viewMode === "plugins" ? ui.pluginsPageDesc : viewMode === "knowledge" ? ui.knowledgePageDesc : ui.settingsDesc;
useEffect(() => {
if (!infoText) {
return;
......@@ -2203,6 +2241,7 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [infoText]);
useEffect(() => () => {
if (copiedTokenResetRef.current !== null) {
window.clearTimeout(copiedTokenResetRef.current);
......@@ -4011,7 +4050,6 @@ export default function App() {
function openSession(sessionId: string) {
setViewMode((current) => (current === "experts" ? "experts" : "chat"));
setActiveSessionId(sessionId);
setSessionActionMenuId("");
}
async function switchExpert(projectId: string) {
......@@ -4064,11 +4102,29 @@ export default function App() {
return nextWorkspace;
}
// 处理分类弹出层中的专家选择
function handleExpertSelect(entry: SidebarExpertEntry) {
const isStandalone = entry.definition.entryMode === "standalone";
if (isStandalone) {
if (entry.project) {
void switchExpert(entry.project.id);
} else {
setErrorText(`${entry.displayName} 项目未下发`);
}
} else {
void activateHomeShortcut(entry);
}
setExpandedCategories({}); // 关闭所有展开的分类
}
async function handleNavSelection(mode: ViewMode) {
if (mode === "chat") {
await openHomeChat();
return;
}
if (mode === "knowledge") {
// 知识库特定逻辑(如有)
}
setViewMode(mode);
}
......@@ -4136,11 +4192,11 @@ export default function App() {
const sidebarNewSessionAction = (
<button
type="button"
className="sidebar-new-session app-no-drag !flex !min-h-12 !items-center !justify-center !gap-2 !rounded-[18px] !border !border-[#d7e8ff] !bg-white !px-4 !text-[14px] !font-semibold !text-[#1d4ed8] !shadow-[0_14px_30px_rgba(59,130,246,0.08)] transition hover:!border-[#bfdbfe] hover:!bg-[#f8fbff]"
className="sidebar-new-session conversation-new-session app-no-drag"
disabled={projectActionPending || !isBound || !projects.length}
onClick={() => void createProjectSession()}
>
<span className="text-lg leading-none">+</span>
<span className="conversation-new-session-plus" aria-hidden="true">+</span>
<span className="conversation-new-session-label">新对话</span>
</button>
);
......@@ -4159,7 +4215,7 @@ export default function App() {
{renderExpertIcon(activeExpertVisualKey)}
</span>
<span className="expert-hero-copy">
<span className="expert-hero-label">当前专家</span>
{/* <span className="expert-hero-label">当前专家</span> */}
<strong>{conversationPanelTitle}</strong>
</span>
</div>
......@@ -4228,7 +4284,7 @@ export default function App() {
</section>
) : null}
<div className="douyin-guide-footer">
<span className="douyin-guide-label">可以直接这样发</span>
{/* <span className="douyin-guide-label">可以直接这样发</span> */}
<p>{ui.starterQuestionsHint}</p>
<div className="starter-prompt-list">
{activeExpertGuide.prompts.map((item) => (
......@@ -4253,7 +4309,6 @@ export default function App() {
<div className="empty-state home-empty-state">
<span className="empty-state-kicker">{selectedSkillBadge}</span>
<strong>{homeChatCopy.emptyTitle}</strong>
<p>{homeChatCopy.emptyDesc}</p>
<div className="starter-prompt-list">
{homeChatCopy.prompts.map((item) => (
<button key={item} type="button" className="starter-prompt" onClick={() => applyStarterPrompt(item)}>{item}</button>
......@@ -4263,7 +4318,7 @@ export default function App() {
);
const messageListContent = (
<div className={"message-list chat-scroll-smooth !flex !min-h-0 !w-full !flex-1 !flex-col !gap-7 !overflow-y-auto !bg-transparent !px-0 !py-3" + (viewMode === "chat" ? " message-list-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " message-list-xiaohongshu" : "")}>
<div className={"message-list chat-scroll-smooth" + (viewMode === "chat" ? " message-list-home" : "")}>
{messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null;
......@@ -4274,8 +4329,8 @@ export default function App() {
const copyToken = `message:${message.id}`;
const reaction = messageReactions[message.id];
return (
<article key={message.id} className={"message-card group relative !w-full !max-w-full !min-w-0 " + message.role + (message.streamState ? " " + message.streamState : "") + (message.role === "user" ? " !flex !justify-end" : " !flex !justify-start")}>
<div className={"message-bubble " + (message.role === "assistant" ? "!w-full !max-w-full !min-w-0 !rounded-none !border-0 !bg-transparent !pl-[2ch] !pr-0 !py-0 !shadow-none" : "animate-user-bubble-in !ml-auto !inline-flex !w-fit !max-w-[min(82%,720px)] !min-w-0 !flex-col !rounded-[20px] !border !border-[#dbeafe] !bg-[#f0f7ff] !px-5 !py-4 !shadow-[0_12px_30px_rgba(59,130,246,0.08)]")}>
<article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}>
<div className={"message-bubble" + (message.role === "assistant" ? " message-bubble-assistant" : " message-bubble-user")}>
{showThinking ? (
videoStatusCard ? (
<div className="generation-status-card" aria-live="polite">
......@@ -4299,7 +4354,7 @@ export default function App() {
)
) : message.content ? (
message.role === "assistant" ? (
<div className="markdown-body !gap-4 text-[15px] leading-8 text-[#0f172a]">
<div className="markdown-body">
{renderMarkdownContent(message.content, {
messageId: message.id,
copiedToken,
......@@ -4308,7 +4363,7 @@ export default function App() {
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</div>
) : (
<p className="message-plain-text !m-0 text-[15px] leading-8 text-[#0f172a]">
<p className="message-plain-text">
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
......@@ -4334,10 +4389,10 @@ export default function App() {
) : null}
</div>
{message.role === "assistant" && canCopyMessage ? (
<div className="message-card-actions !mt-3 !justify-start !opacity-0 !transition !duration-150 group-hover:!opacity-100">
<div className="message-card-actions">
<button
type="button"
className={"message-action-icon !h-8 !w-8 !rounded-full !border-0 !bg-white !text-[#64748b] !shadow-[0_10px_24px_rgba(148,163,184,0.18)] hover:!bg-[#f8fbff] " + (copiedToken === copyToken ? " copied !bg-[#ecfdf3] !text-[#16a34a]" : "")}
className={"message-action-icon" + (copiedToken === copyToken ? " copied" : "")}
onClick={() => void handleCopyText(copyToken, message.content)}
aria-label="复制消息"
title="复制消息"
......@@ -4431,7 +4486,12 @@ export default function App() {
const composerContent = (
<form
className={"composer-shell relative !mt-2 !flex !w-full !flex-col !gap-3 !rounded-[24px] !border !border-[#d7e8ff] !bg-white !px-5 !py-4 !shadow-[0_24px_60px_rgba(148,163,184,0.14)]" + (isComposerDragOver ? " dragging !border-[#60a5fa] !bg-[#f8fbff]" : "") + (viewMode === "chat" ? " composer-shell-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " composer-shell-xiaohongshu" : "")}
className={
"composer-shell"
+ (isComposerDragOver ? " dragging" : "")
+ (viewMode === "chat" ? " composer-shell-home" : "")
+ (viewMode === "experts" ? " composer-shell-expert" : "")
}
onSubmit={(event) => {
event.preventDefault();
void sendPrompt();
......@@ -4449,83 +4509,80 @@ export default function App() {
tabIndex={-1}
onChange={handleAttachmentSelection}
/>
{isComposerDragOver ? <div className="composer-drop-indicator !min-h-14 !rounded-[18px] !border-dashed !border-[#93c5fd] !bg-[#f0f7ff] !text-[#2563eb]">释放以上传图片</div> : null}
<label className="composer-field !gap-0">
<textarea
value={prompt}
disabled={!isBound}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => void handleComposerKeyDown(event)}
placeholder={composerPlaceholder}
className="!min-h-[60px] !rounded-none !border-0 !bg-transparent !p-0 !text-[15px] !leading-8 !text-[#0f172a] placeholder:!text-transparent"
/>
</label>
{composerAttachment ? (
<div className="composer-attachment-strip !mt-0">
<span className="composer-attachment-chip !rounded-full !border !border-[#d7e8ff] !bg-[#f0f7ff] !px-3 !py-2">
<span className="composer-attachment-chip-label">{composerAttachment.name}</span>
<button type="button" className="composer-attachment-remove" onClick={() => clearComposerAttachment()} aria-label="移除图片附件">
x
{isComposerDragOver ? <div className="composer-drop-indicator">释放以上传图片</div> : null}
<div className="composer-surface">
<label className="composer-field">
<textarea
value={prompt}
disabled={!isBound}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => void handleComposerKeyDown(event)}
placeholder={composerPlaceholder}
className="composer-textarea"
/>
</label>
{composerAttachment ? (
<div className="composer-attachment-strip">
<span className="composer-attachment-chip">
<span className="composer-attachment-chip-label">{composerAttachment.name}</span>
<button type="button" className="composer-attachment-remove" onClick={() => clearComposerAttachment()} aria-label="移除图片附件">
x
</button>
</span>
</div>
) : null}
<div className="composer-footer">
<div className="composer-left-tools" ref={skillMenuRef}>
<button type="button" className="attachment-trigger icon-only" disabled={!isBound || sending} onClick={openAttachmentPicker} aria-label="上传图片" title="上传图片">
<AttachmentIcon />
</button>
</span>
</div>
) : null}
<div className="composer-footer !items-end !justify-between !gap-4">
<div className="composer-left-tools !flex-1 !items-center !gap-2" ref={skillMenuRef}>
<button type="button" className="attachment-trigger icon-only !h-11 !w-11 !rounded-full !border !border-[#d7e8ff] !bg-[#f0f7ff] !text-[#2563eb] hover:!bg-[#e0f2fe]" disabled={!isBound || sending} onClick={openAttachmentPicker} aria-label="上传图片" title="上传图片">
<AttachmentIcon />
</button>
{viewMode === "experts" ? (
<button type="button" className="attachment-trigger" disabled={!isBound || sending} onClick={openAttachmentPicker}>
图片
<button type="button" className="skill-trigger" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
@
</button>
) : null}
<button type="button" className="skill-trigger !h-11 !rounded-full !border !border-[#e2e8f0] !bg-white !px-4 !text-[#334155]" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
@
{selectedSkillId !== DEFAULT_SKILL.id ? (
<button type="button" className="skill-chip" disabled={!isBound} onClick={() => clearSelectedSkill()}>
{"@" + selectedSkill.name}
</button>
) : null}
{skillMenuOpen ? (
<div className="skill-menu" role="menu" aria-label={ui.skillMenuTitle}>
{effectiveSkills.map((skill) => {
const active = skill.id === selectedSkillId;
return (
<button
key={skill.id}
type="button"
role="menuitemradio"
aria-checked={active}
className={"skill-menu-item" + (active ? " active" : "")}
onClick={() => chooseSkill(skill.id)}
>
<strong>{skill.id === DEFAULT_SKILL.id ? ui.defaultChat : "@" + skill.name}</strong>
<span>{skill.description}</span>
</button>
);
})}
</div>
) : null}
</div>
<button
type="submit"
className={"composer-submit" + (sending ? " is-busy" : "")}
disabled={!canSend}
aria-label={sendButtonLabel}
title={sendButtonLabel}
>
{sending ? <span className="composer-submit-spinner" aria-hidden="true" /> : <ArrowUpIcon />}
<span className="visually-hidden">{sendButtonLabel}</span>
</button>
{selectedSkillId !== DEFAULT_SKILL.id ? (
<button type="button" className="skill-chip" disabled={!isBound} onClick={() => clearSelectedSkill()}>
{"@" + selectedSkill.name}
</button>
) : null}
{skillMenuOpen ? (
<div className="skill-menu" role="menu" aria-label={ui.skillMenuTitle}>
{effectiveSkills.map((skill) => {
const active = skill.id === selectedSkillId;
return (
<button
key={skill.id}
type="button"
role="menuitemradio"
aria-checked={active}
className={"skill-menu-item" + (active ? " active" : "")}
onClick={() => chooseSkill(skill.id)}
>
<strong>{skill.id === DEFAULT_SKILL.id ? ui.defaultChat : "@" + skill.name}</strong>
<span>{skill.description}</span>
</button>
);
})}
</div>
) : null}
</div>
<button
type="submit"
className={"composer-submit !h-12 !w-12 !rounded-full !border-0 !bg-[#2563eb] !text-white !shadow-[0_16px_30px_rgba(37,99,235,0.28)] hover:!bg-[#1d4ed8] " + (sending ? " is-busy" : "")}
disabled={!canSend}
aria-label={sendButtonLabel}
title={sendButtonLabel}
>
{sending ? <span className="composer-submit-spinner" aria-hidden="true" /> : <ArrowUpIcon />}
<span className="visually-hidden">{sendButtonLabel}</span>
</button>
<p className="composer-hint">按 Enter 发送,Shift + Enter 换行</p>
</div>
<p className="composer-hint !m-0 !text-[11px] !text-[#94a3b8]">按 Enter 发送,Shift + Enter 换行</p>
</form>
);
return (
<div className="shell openclaw-theme !grid !grid-cols-[280px_minmax(0,1fr)] !bg-[#f0f7ff]">
<div className={"shell openclaw-theme" + (isConversationView ? " conversation-shell" : "") + (viewMode === "experts" ? " conversation-shell-experts" : "")}>
<div className="window-controls" aria-label="窗口控制">
<button type="button" className="window-control-button" aria-label="最小化窗口" onClick={minimizeWindow}>
<WindowControlIcon kind="minimize" />
......@@ -4537,9 +4594,9 @@ export default function App() {
<WindowControlIcon kind="close" />
</button>
</div>
<aside className="sidebar app-drag-region !w-[280px] !border-r !border-[#dbeafe] !bg-[#f9fbff] !px-4 !py-5">
<div className="sidebar-top !gap-4">
<div className="sidebar-logo-block !rounded-[20px] !border !border-[#dbeafe] !bg-white !px-4 !py-4 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]" aria-label="千匠问天">
<aside className={"sidebar app-drag-region" + (isConversationView ? " conversation-sidebar-layout" : "")}>
<div className="sidebar-top">
<div className="sidebar-logo-block" aria-label="千匠问天">
<div className="sidebar-logo-mark-shell" aria-hidden="true">
<img src={brandIcon} alt="" className="sidebar-logo-mark" />
</div>
......@@ -4547,14 +4604,15 @@ export default function App() {
<strong>千匠问天</strong>
</div>
</div>
<nav className="nav-list !gap-2">
<nav className="nav-list">
{[
{ id: "chat" as const, label: "对话" },
{ id: "experts" as const, label: ui.experts },
{ id: "experts" as const, label: "数字员工" },
{ id: "knowledge" as const, label: ui.knowledge },
{ id: "plugins" as const, label: ui.plugins },
{ id: "settings" as const, label: ui.settings }
].map((item) => (
<button key={item.id} type="button" className={"nav-item app-no-drag !min-h-12 !rounded-[18px] !px-4 !text-[14px] !font-medium !shadow-none transition " + (viewMode === item.id ? " active !bg-[#f0f7ff] !text-[#1d4ed8] !shadow-[inset_0_0_0_1px_rgba(191,219,254,0.95)]" : "!bg-transparent !text-[#334155] hover:!bg-white hover:!text-[#0f172a]")} onClick={() => void handleNavSelection(item.id)}>
<button key={item.id} type="button" className={"nav-item app-no-drag" + (viewMode === item.id ? " active" : "")} onClick={() => void handleNavSelection(item.id)}>
<span className="nav-item-icon" aria-hidden="true">
<NavIcon kind={item.id} />
</span>
......@@ -4564,83 +4622,118 @@ export default function App() {
</nav>
{!showBindEntry ? sidebarNewSessionAction : null}
</div>
<div className="sidebar-bottom !gap-4">
<section className="sidebar-section compact sidebar-experts-entry !rounded-[20px] !border !border-[#dbeafe] !bg-white !p-3 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]">
{sidebarExpertEntries.length ? (
<div className="sidebar-expert-scroll">
<div className="expert-chip-list preview !gap-2">
{sidebarExpertEntries.map((entry) => {
const expertVisualKey = resolveExpertVisualKey(entry.project, entry.definition);
const isStandalone = entry.definition.entryMode === "standalone";
const isActive = isStandalone
? viewMode === "experts" && entry.project?.id === activeProject?.id
: viewMode === "chat" && prompt.trim() === buildShortcutPrompt(entry.definition);
return (
<button
key={entry.definition.id}
type="button"
className={"expert-chip expert-chip-" + expertVisualKey + " app-no-drag !min-h-14 !rounded-[18px] !border !px-3 !py-3 !shadow-none transition " + (isActive ? " active !border-[#bfdbfe] !bg-[#f0f7ff]" : "!border-transparent !bg-[#f8fbff] hover:!border-[#dbeafe] hover:!bg-white")}
disabled={projectActionPending || !entry.isAvailable}
onClick={() => {
if (isStandalone) {
if (entry.project) {
void switchExpert(entry.project.id);
} else {
setErrorText(`${entry.displayName} 项目未下发`);
}
return;
}
void activateHomeShortcut(entry);
}}
<div className="sidebar-bottom">
<section className="sidebar-section compact sidebar-experts-entry">
<div className="sidebar-expert-scroll">
<div className="expert-category-list">
{CATEGORY_CONFIG.map((category) => {
// 获取该分类的专家
const categoryExperts = sidebarExpertEntries.filter(entry => {
const expertId = entry.definition.id;
const expertName = entry.displayName;
const expertSeed = (expertId + ' ' + expertName).toLowerCase();
// 根据分类ID进行匹配
if (category.id === 'content') {
// 内容营销:小红书、抖音、内容账号规划、知乎
return /xiaohongshu|xhs|rednote|小红书|douyin|抖音|content-account|内容账号规划|zhihu|知乎/.test(expertSeed);
} else if (category.id === 'acquisition') {
// 精准获客:GEO、平台精准线索
return /geo|precision-leads|精准线索|线索/.test(expertSeed);
} else if (category.id === 'sales') {
// 销售冠军
return /sales-champion|销售冠军|销冠/.test(expertSeed);
} else if (category.id === 'other') {
// 其他专家:不匹配以上分类的专家
return !(
/xiaohongshu|xhs|rednote|小红书|douyin|抖音|content-account|内容账号规划|zhihu|知乎/.test(expertSeed) ||
/geo|precision-leads|精准线索|线索/.test(expertSeed) ||
/sales-champion|销售冠军|销冠/.test(expertSeed)
);
}
return false;
});
const isExpanded = expandedCategories[category.id] || false;
return (
<div
key={category.id}
className="expert-category-item"
>
{/* 分类头部 - 可点击展开/收起 */}
<div
className="expert-category-header"
onClick={() => toggleCategory(category.id)}
>
<span className={"expert-chip-icon expert-chip-icon-" + expertVisualKey} aria-hidden="true">
{renderExpertIcon(expertVisualKey)}
</span>
<span className="expert-chip-copy">{entry.displayName}</span>
</button>
);
})}
</div>
<div className="expert-category-icon">{category.icon}</div>
<div className="expert-category-title">
<div className="expert-category-name">{category.name}</div>
</div>
<div className={`expert-category-toggle ${isExpanded ? 'expanded' : ''}`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
{/* 展开的内容区域 */}
{isExpanded && categoryExperts.length > 0 && (
<div className="expert-category-content">
<div className="expert-category-experts">
{categoryExperts.map((entry) => {
const expertVisualKey = resolveExpertVisualKey(entry.project, entry.definition);
const isStandalone = entry.definition.entryMode === "standalone";
const isActive = isStandalone
? viewMode === "experts" && entry.project?.id === activeProject?.id
: viewMode === "chat" && prompt.trim() === buildShortcutPrompt(entry.definition);
return (
<div
key={entry.definition.id}
className="expert-category-expert-item"
onClick={() => handleExpertSelect(entry)}
>
<div className="expert-category-expert-icon">
{renderExpertIcon(expertVisualKey)}
</div>
<div className="expert-category-expert-name">{entry.displayName}</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
) : null}
</div>
</section>
{!showBindEntry ? (
<section className="sidebar-section sidebar-section-fill compact sidebar-session-section !rounded-[20px] !border !border-[#dbeafe] !bg-white !p-3 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]">
<section className="sidebar-section sidebar-section-fill compact sidebar-session-section">
<div className="sidebar-section-head sidebar-section-head-subtle">
<div className="sidebar-section-copy">
<span className="sidebar-section-label">{sidebarSessionLabel}</span>
</div>
</div>
<div className="sidebar-session-list !gap-2">
<div className="sidebar-session-list">
{sessions.map((session, index) => (
<div key={session.id} className={"sidebar-session-card !rounded-[16px]" + (activeSessionId === session.id ? " active !bg-[#f0f7ff]" : " hover:!bg-[#f8fbff]")}>
<button type="button" className="sidebar-session-main app-no-drag !min-h-12 !rounded-[16px] !px-3 !text-left !shadow-none" disabled={projectActionPending} onClick={() => openSession(session.id)}>
<div key={session.id} className={"sidebar-session-card" + (activeSessionId === session.id ? " active" : "")}>
<button type="button" className="sidebar-session-main app-no-drag" disabled={projectActionPending} onClick={() => openSession(session.id)}>
<strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button>
{sessions.length > 1 ? (
<div className="sidebar-session-actions">
<button
type="button"
className={"sidebar-session-close" + (sessionActionMenuId === session.id ? " active" : "")}
aria-label="会话操作"
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => setSessionActionMenuId((current) => current === session.id ? "" : session.id)}
>
<MoreIcon />
</button>
{sessionActionMenuId === session.id ? (
<div className="sidebar-session-menu">
<button
type="button"
className="sidebar-session-menu-item"
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => void closeProjectSession(session.id)}
>
{ui.closeSession}
</button>
</div>
) : null}
</div>
<button
type="button"
className="sidebar-session-close app-no-drag"
aria-label={ui.closeSession}
title={ui.closeSession}
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => void closeProjectSession(session.id)}
>
x
</button>
) : null}
</div>
))}
......@@ -4649,8 +4742,8 @@ export default function App() {
) : null}
</div>
</aside>
<div className="main-shell !bg-[#f0f7ff]">
{!isConversationView ? (
<div className={"main-shell" + (isConversationView ? " conversation-main-layout" : "")}>
{!isConversationView && viewMode !== "knowledge" ? (
<div className="page-topbar">
<div className="page-copy">
<h2>{pageTitle}</h2>
......@@ -4665,182 +4758,267 @@ export default function App() {
) : null}
{infoText ? <div className="notice toast-notice">{infoText}</div> : null}
{errorText ? <div className="notice error">{errorText}</div> : null}
<main className="content-area">
<main className={"content-area" + (isConversationView ? " conversation-content-area" : "")}>
{isConversationView ? (
<section className={"panel chat-panel conversation-panel !gap-5 !rounded-none !bg-transparent !px-7 !py-6" + (viewMode === "chat" ? " conversation-panel-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-xiaohongshu" : "")}>
<div className="conversation-panel-head conversation-panel-head-layout app-drag-region !grid !items-center !gap-4 !border-0 !pb-0">
<section className="chat-panel conversation-panel">
<div className="conversation-panel-head conversation-panel-head-layout app-drag-region">
<div className="conversation-panel-copy">
{conversationPanelLead}
</div>
<div className="conversation-drag-strip" aria-hidden="true" />
<div className="conversation-panel-actions app-no-drag !flex !items-center !gap-2">
<div className="conversation-panel-actions app-no-drag">
<StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip>
{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}
</div>
</div>
<div className={"conversation-panel-body !flex !min-h-0 !flex-1 !flex-col !overflow-hidden !rounded-[28px] !border !border-[#dbeafe] !bg-white/78 !px-0 !py-6 !shadow-[0_24px_60px_rgba(148,163,184,0.12)] backdrop-blur" + (viewMode === "chat" ? " conversation-panel-body-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-body-xiaohongshu" : "")}>
<div className="conversation-workspace">
{conversationStatusNotice}
{viewMode === "chat" ? homeIntentSuggestionNotice : null}
{conversationBodyContent}
<div className="conversation-panel-body">
{conversationBodyContent}
</div>
{composerContent}
</div>
{composerContent}
</section>
) : null}
{viewMode === "plugins" ? (
<section className="panel catalog-list plugin-page">
<div className="scroll-panel plugin-section-list plugin-flat-list">
<div className="plugin-grid plugin-flat-grid">
{pluginCards.map(({ plugin, sectionTitle }) => {
const copy = getPluginCopy(plugin);
return (
<article key={plugin.id} className="catalog-item static plugin-card plugin-flat-card">
<div className="plugin-card-glow" aria-hidden="true" />
<div className="plugin-card-claw" aria-hidden="true">
<LobsterClawIcon />
</div>
<div className="plugin-card-meta">
<span className="plugin-card-group">{sectionTitle}</span>
<StatusChip tone={getPluginTone(plugin.status)}>{getPluginStatusLabel(plugin.status)}</StatusChip>
</div>
<div className="plugin-card-head">
<strong>{copy.name}</strong>
</div>
<p>{copy.description}</p>
</article>
);
})}
<div className="page-stack plugin-page-stack">
<section className="panel plugin-page">
<div className="scroll-panel plugin-section-list plugin-flat-list">
<div className="plugin-grid plugin-flat-grid">
{pluginCards.map(({ plugin, sectionTitle }) => {
const copy = getPluginCopy(plugin);
return (
<article key={plugin.id} className="catalog-item static plugin-card plugin-flat-card">
<div className="plugin-card-glow" aria-hidden="true" />
<div className="plugin-card-claw" aria-hidden="true">
<LobsterClawIcon />
</div>
<div className="plugin-card-meta">
<span className="plugin-card-group">{sectionTitle}</span>
<StatusChip tone={getPluginTone(plugin.status)}>{getPluginStatusLabel(plugin.status)}</StatusChip>
</div>
<div className="plugin-card-head">
<strong>{copy.name}</strong>
</div>
<p>{copy.description}</p>
</article>
);
})}
</div>
{!pluginCards.length ? <div className="empty-state">{ui.noPlugins}</div> : null}
</div>
{!pluginCards.length ? <div className="empty-state">{ui.noPlugins}</div> : null}
</div>
</section>
</section>
</div>
) : null}
{viewMode === "settings" ? (
<div className="page-stack settings-page-stack">
{showSettingsStatusHint ? <div className={"inline-hint settings-runtime-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
<section className="panel settings-panel settings-panel-hero compact settings-panel-modern">
<div className="settings-section-card">
<div className="settings-section-headline">
<div>
<span className="settings-section-kicker">基础连接</span>
<h4>客户端绑定</h4>
{viewMode === "knowledge" ? (
<div className="page-stack knowledge-page-stack">
<section className="panel knowledge-panel">
<div className="knowledge-header">
<h1 className="knowledge-title">企业知识库</h1>
<p className="knowledge-subtitle">上传和管理您的企业知识文档</p>
</div>
<div className="knowledge-upload-section">
<div className="upload-card">
<div className="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h3 className="upload-title">上传文档</h3>
<p className="upload-desc">支持 PDF、Word、Excel、TXT 等格式</p>
<button className="upload-button btn-primary">
选择文件上传
</button>
<p className="upload-hint">或拖拽文件到此处</p>
</div>
<div className="settings-field-grid single">
<label className="settings-input-label">
<span className="settings-input-label-text">员工密钥</span>
<input
type="password"
value={lobsterKeyDraft}
placeholder={workspace?.apiKeyConfigured ? "输入新的龙虾密钥以更新绑定" : "请输入龙虾密钥"}
onChange={(event) => setLobsterKeyDraft(event.target.value)}
/>
</label>
</div>
<div className="button-row settings-actions">
<button disabled={saving || !hasPendingLobsterKey} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button>
</div>
<div className="knowledge-list-section">
<h2 className="section-title">最近文档</h2>
<div className="document-grid">
{/* 文档列表占位符 */}
<div className="document-card">
<div className="document-icon">📄</div>
<div className="document-info">
<h4>产品手册.pdf</h4>
<p>更新于 2 小时前 · 2.4 MB</p>
</div>
</div>
</div>
</div>
<div className="settings-section-card">
<div className="settings-section-headline">
<div>
<span className="settings-section-kicker">专家模型配置</span>
<h4>按能力分别配置</h4>
<p>固定参数已内置,只保留客户需要填写的密钥。</p>
</section>
</div>
) : null}
{viewMode === "settings" ? (
<div className="page-stack settings-page-stack settings-page-shell">
{showSettingsStatusHint ? <div className={"inline-hint settings-runtime-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
<div className="settings-console-grid">
<section className="panel settings-panel settings-panel-modern settings-panel-connection">
<div className="settings-section-card settings-section-card-compact">
<div className="settings-section-headline">
<div>
<span className="settings-section-kicker">基础配置</span>
<h4>客户端绑定</h4>
</div>
<StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? "已绑定" : "未绑定"}</StatusChip>
</div>
<div className="settings-field-grid single">
<label className="settings-input-label">
<span className="settings-input-label-text">龙虾密钥</span>
<input
type="password"
value={lobsterKeyDraft}
placeholder={workspace?.apiKeyConfigured ? "输入新的龙虾密钥或更新绑定" : "请输入龙虾密钥"}
onChange={(event) => setLobsterKeyDraft(event.target.value)}
/>
</label>
</div>
<div className="button-row settings-actions">
<button className="settings-primary-button" disabled={saving || !hasPendingLobsterKey} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button>
</div>
</div>
<div className="model-config-grid model-config-grid-four">
<article className="model-config-card">
<div className="model-config-card-head">
<div>
<strong>文案模型</strong>
<p>用于标题、脚本、口播稿与内容润色。</p>
</div>
<StatusChip tone={config?.expertModelConfig.copywriting.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.copywriting.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</section>
<section className="panel settings-panel settings-panel-secondary settings-panel-diagnostics">
<div className="settings-section-card settings-section-card-compact">
<div className="settings-section-headline">
<div>
<span className="settings-section-kicker">诊断与工作区</span>
{/* <h4>当前生效目录</h4> */}
</div>
<div className="settings-field-grid single">
<label className="settings-input-label">
<span className="settings-input-label-text">API Key</span>
<input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} />
</label>
<StatusChip tone={hasPendingWorkspacePathChange ? "warning" : "info"}>{hasPendingWorkspacePathChange ? "待保存" : "已同步"}</StatusChip>
</div>
<div className="workspace-directory-card">
<div className="workspace-directory-panel">
<span className="workspace-directory-eyebrow">当前生效目录</span>
<strong className="workspace-directory-path">{displayedWorkspacePath}</strong>
{/* <p className="workspace-directory-hint">项目会从该目录加载;保存后会重新预热工作区。</p> */}
</div>
</article>
<article className="model-config-card">
<div className="model-config-card-head">
<div>
<strong>生图模型</strong>
<p>用于封面草图、画面创意和视觉素材生成。</p>
{hasPendingWorkspacePathChange ? (
<div className="workspace-directory-draft-row">
<span className="workspace-directory-draft-badge">待保存</span>
<div className="workspace-directory-inline-actions">
<button disabled={saving} onClick={() => void saveWorkspaceDirectory()}>{saving ? ui.saving : ui.save}</button>
<button className="secondary workspace-directory-inline-button" disabled={saving} onClick={restoreWorkspaceDirectory}>恢复当前</button>
</div>
</div>
<StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="settings-field-grid single">
<label className="settings-input-label">
<span className="settings-input-label-text">API Key</span>
<input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} />
</label>
</div>
</article>
<article className="model-config-card">
<div className="model-config-card-head">
<div>
<strong>普通视频模型</strong>
<p>用于纯画面视频生成,客户只需要配置一个 Key。</p>
) : null}
</div>
<div className="button-row settings-actions workspace-directory-actions">
<button className="settings-primary-button" disabled={saving || !config} onClick={() => void pickWorkspaceDirectory()}>更改目录</button>
<button className="secondary" disabled={saving} onClick={() => void exportDiagnostics()}>{ui.export}</button>
</div>
</div>
</section>
<section className="panel settings-panel settings-panel-models">
<div className="settings-section-card settings-section-card-models">
<div className="settings-section-headline settings-section-headline-minimal">
<span className="settings-section-kicker">专家模型配置</span>
</div>
<div className="model-config-grid model-config-grid-four">
<article className="model-config-card model-config-card-copywriting">
<div className="model-config-card-head">
<div>
<strong>文案模型</strong>
<p>用于标题、脚本、口播稿与内容润色</p>
</div>
<StatusChip tone={config?.expertModelConfig.copywriting.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.copywriting.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="settings-field-grid single">
<label className="settings-input-label">
<span className="settings-input-label-text">API Key</span>
<input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} />
</label>
</div>
</article>
<article className="model-config-card">
<div className="model-config-card-head">
<div>
<strong>数字人配置</strong>
<p>用于数字人口播视频,火山与七牛的四个 Key 由客户填写。</p>
<div className="model-config-card-body">
<div className="settings-field-grid single">
<label className="settings-input-label">
{/* <span className="settings-input-label-text">API Key</span> */}
<input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} />
</label>
</div>
</div>
<StatusChip tone={
config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured
? "positive"
: "warning"
}>
{config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured
? "已配置"
: "未配置"}
</StatusChip>
</div>
<div className="settings-field-grid">
<label className="settings-input-label">
<span className="settings-input-label-text">VOLC_ACCESS_KEY</span>
<input type="password" value={digitalHumanVolcAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"} onChange={(event) => setDigitalHumanVolcAccessKeyDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">VOLC_SECRET_KEY</span>
<input type="password" value={digitalHumanVolcSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"} onChange={(event) => setDigitalHumanVolcSecretKeyDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">QINIU_ACCESS_KEY</span>
<input type="password" value={digitalHumanQiniuAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"} onChange={(event) => setDigitalHumanQiniuAccessKeyDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">QINIU_SECRET_KEY</span>
<input type="password" value={digitalHumanQiniuSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"} onChange={(event) => setDigitalHumanQiniuSecretKeyDraft(event.target.value)} />
</label>
</div>
</article>
</div>
<div className="button-row settings-actions">
<button disabled={saving || !hasPendingModelKeys} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button>
</article>
<article className="model-config-card model-config-card-image">
<div className="model-config-card-head">
<div>
<strong>生图模型</strong>
<p>用于封面草图、画面创意和视觉素材生成</p>
</div>
<StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
<label className="settings-input-label">
{/* <span className="settings-input-label-text">API Key</span> */}
<input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} />
</label>
</div>
</div>
</article>
<article className="model-config-card model-config-card-video">
<div className="model-config-card-head">
<div>
<strong>普通视频模型</strong>
<p>用于纯画面视频生成</p>
</div>
<StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
<label className="settings-input-label">
{/* <span className="settings-input-label-text">API Key</span> */}
<input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} />
</label>
</div>
</div>
</article>
<article className="model-config-card model-config-card-digital-human">
<div className="model-config-card-head">
<div>
<strong>数字人配置</strong>
<p>用于数字人口播视频,火山与七牛的四个 Key</p>
</div>
<StatusChip tone={
config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured
? "positive"
: "warning"
}>
{config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured
&& config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured
? "已配置"
: "未配置"}
</StatusChip>
</div>
<div className="model-config-card-body model-config-card-body-digital-human">
<div className="settings-field-grid settings-field-grid-digital-human">
<label className="settings-input-label">
<span className="settings-input-label-text">VOLC_ACCESS_KEY</span>
<input type="password" value={digitalHumanVolcAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"} onChange={(event) => setDigitalHumanVolcAccessKeyDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">VOLC_SECRET_KEY</span>
<input type="password" value={digitalHumanVolcSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"} onChange={(event) => setDigitalHumanVolcSecretKeyDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">QINIU_ACCESS_KEY</span>
<input type="password" value={digitalHumanQiniuAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"} onChange={(event) => setDigitalHumanQiniuAccessKeyDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">QINIU_SECRET_KEY</span>
<input type="password" value={digitalHumanQiniuSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"} onChange={(event) => setDigitalHumanQiniuSecretKeyDraft(event.target.value)} />
</label>
</div>
</div>
</article>
</div>
<div className="button-row settings-actions">
<button className="settings-primary-button" disabled={saving || !hasPendingModelKeys} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button>
</div>
</div>
</div>
</section>
</section>
</div>
{false ? (
<section className="panel settings-panel settings-panel-hero">
<div className="section-head compact settings-hero-status-row">
......@@ -4952,35 +5130,6 @@ export default function App() {
{showSettingsStatusHint ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
</section>
) : null}
<section className="panel settings-panel">
<div className="settings-section-card">
<div className="settings-section-headline">
<div>
<span className="settings-section-kicker">诊断与工作区</span>
</div>
</div>
<div className="workspace-directory-card">
<div className="workspace-directory-panel">
<span className="workspace-directory-eyebrow">当前生效目录</span>
<strong className="workspace-directory-path">{displayedWorkspacePath}</strong>
<p className="workspace-directory-hint">项目会从该目录加载;保存后会重新预热工作区。</p>
</div>
{hasPendingWorkspacePathChange ? (
<div className="workspace-directory-draft-row">
<span className="workspace-directory-draft-badge">待保存</span>
<div className="workspace-directory-inline-actions">
<button disabled={saving} onClick={() => void saveWorkspaceDirectory()}>{saving ? ui.saving : ui.save}</button>
<button className="secondary workspace-directory-inline-button" disabled={saving} onClick={restoreWorkspaceDirectory}>恢复当前</button>
</div>
</div>
) : null}
</div>
<div className="button-row settings-actions workspace-directory-actions">
<button disabled={saving || !config} onClick={() => void pickWorkspaceDirectory()}>更改目录</button>
<button className="secondary" disabled={saving} onClick={() => void exportDiagnostics()}>{ui.export}</button>
</div>
</div>
</section>
</div>
) : null}
</main>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment