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 { ...@@ -29,7 +29,7 @@ import {
FIXED_EXPERT_MODEL_ENDPOINTS FIXED_EXPERT_MODEL_ENDPOINTS
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
type ViewMode = "chat" | "experts" | "plugins" | "settings"; type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge";
type Tone = "positive" | "warning" | "info"; type Tone = "positive" | "warning" | "info";
type MessageStreamState = "streaming" | "error"; type MessageStreamState = "streaming" | "error";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"; type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
...@@ -510,7 +510,7 @@ function getIntentSuggestionIcon(platform?: string): ReactNode { ...@@ -510,7 +510,7 @@ function getIntentSuggestionIcon(platform?: string): ReactNode {
return <BrowserExpertIcon />; return <BrowserExpertIcon />;
} }
function NavIcon({ kind }: { kind: "chat" | "experts" | "plugins" | "settings" }) { function NavIcon({ kind }: { kind: "chat" | "experts" | "plugins" | "settings" | "knowledge" }) {
switch (kind) { switch (kind) {
case "chat": case "chat":
return ( return (
...@@ -530,6 +530,13 @@ function NavIcon({ kind }: { kind: "chat" | "experts" | "plugins" | "settings" } ...@@ -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" /> <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> </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": case "settings":
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
...@@ -558,11 +565,13 @@ const ui = { ...@@ -558,11 +565,13 @@ const ui = {
skills: "\u80fd\u529b", skills: "\u80fd\u529b",
experts: "\u4e13\u5bb6", experts: "\u4e13\u5bb6",
plugins: "\u63d2\u4ef6", plugins: "\u63d2\u4ef6",
knowledge: "知识库",
knowledgePageDesc: "上传和管理您的企业知识文档",
settings: "\u8bbe\u7f6e", settings: "\u8bbe\u7f6e",
bound: "\u5df2\u7ed1\u5b9a", bound: "\u5df2\u7ed1\u5b9a",
unbound: "\u672a\u7ed1\u5b9a", unbound: "\u672a\u7ed1\u5b9a",
defaultChat: "千匠问天", defaultChat: "千匠问天",
bindTitle: "绑定员工密钥", bindTitle: "绑定龙虾密钥",
bindDesc: "你好,我是千匠问天。可以先帮你整理 Excel、汇总资料、检索公开信息、拆解执行任务;如果你要做小红书或抖音内容,也可以切到对应专家继续处理。", bindDesc: "你好,我是千匠问天。可以先帮你整理 Excel、汇总资料、检索公开信息、拆解执行任务;如果你要做小红书或抖音内容,也可以切到对应专家继续处理。",
apiKey: "\u5458\u5de5\u5bc6\u94a5", apiKey: "\u5458\u5de5\u5bc6\u94a5",
apiKeyPlaceholder: "\u8bf7\u8f93\u5165 OpenClaw employee api_key", apiKeyPlaceholder: "\u8bf7\u8f93\u5165 OpenClaw employee api_key",
...@@ -655,6 +664,39 @@ const ui = { ...@@ -655,6 +664,39 @@ const ui = {
none: "\u65e0" none: "\u65e0"
} as const; } 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 = { const startupCurtainCopy = {
brandTitle: "\u5343\u5320\u00b7\u95ee\u5929", brandTitle: "\u5343\u5320\u00b7\u95ee\u5929",
brandTagline: "START YOUR IDEAS", brandTagline: "START YOUR IDEAS",
...@@ -672,13 +714,13 @@ const startupCurtainCopy = { ...@@ -672,13 +714,13 @@ const startupCurtainCopy = {
const homeChatCopy = { const homeChatCopy = {
title: "首页对话", title: "首页对话",
microcopy: "从一个实用小任务开始:整理 Excel、汇总资料、检索信息,或把需求拆成执行清单。", microcopy: "从一个实用小任务开始:整理 Excel、汇总资料、检索信息,或把需求拆成执行清单。",
emptyTitle: "先选一个能立刻开始的小任务。", emptyTitle: " 先选一个能立刻开始的小任务",
emptyDesc: "首页适合先做 4 类事:Excel 整理、资料汇总、公开信息检索、需求拆解;明确要做小红书或抖音内容时,再切到对应专家继续处理。", // emptyDesc: "首页适合做 4 类事:Excel 整理、资料汇总、公开信息检索、需求拆解;明确要做小红书或抖音内容时,再切到对应专家继续处理",
prompts: [ prompts: [
"帮我把这份 Excel 台账整理成可汇报的结构。", "我想做海报内容,请帮我先整理主题、卖点层级、标题和版面文案",
"根据这堆资料,提炼 5 条关键信息给我。", "我想做 GEO 方向内容,请先帮我明确目标、策略框架和执行重点",
"帮我查这个主题需要关注哪些公开信息。", "我想做平台精准线索获取,请帮我梳理目标人群、线索标准、触达话术和转化路径",
"把这个需求拆成今天就能执行的清单。" "我想以销售冠军的方式推进成交,请帮我梳理目标客户、沟通话术、异议处理和转化动作"
] ]
} as const; } as const;
...@@ -1388,16 +1430,6 @@ function ThumbIcon({ direction }: { direction: MessageReaction }) { ...@@ -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 { function formatCodeLanguageLabel(language: string): string {
const normalized = language.trim().toLowerCase(); const normalized = language.trim().toLowerCase();
if (!normalized) { if (!normalized) {
...@@ -1833,7 +1865,7 @@ function buildDouyinVideoStatusCard(message: UiChatMessage, expertKey: ReturnTyp ...@@ -1833,7 +1865,7 @@ function buildDouyinVideoStatusCard(message: UiChatMessage, expertKey: ReturnTyp
const [meta, eta, hint] = detail.split(" | ").map((item) => item.trim()).filter(Boolean); const [meta, eta, hint] = detail.split(" | ").map((item) => item.trim()).filter(Boolean);
const metaParts = [meta, eta].filter(Boolean); const metaParts = [meta, eta].filter(Boolean);
return { return {
title: title || "视频正在生成中", title: title || "视频正在生成中...",
meta: metaParts.length ? metaParts.join(" · ") : undefined, meta: metaParts.length ? metaParts.join(" · ") : undefined,
hint: hint || "已进入长任务生成阶段,客户端会持续刷新状态。" hint: hint || "已进入长任务生成阶段,客户端会持续刷新状态。"
}; };
...@@ -1846,10 +1878,9 @@ function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent ...@@ -1846,10 +1878,9 @@ function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent
greeting: "把产品、场景和目标人群说清楚,我先给你一版能直接开工的小红书任务。", greeting: "把产品、场景和目标人群说清楚,我先给你一版能直接开工的小红书任务。",
summary: "适合先做选题判断、笔记结构、标题草案、配图思路和发布时间建议。", summary: "适合先做选题判断、笔记结构、标题草案、配图思路和发布时间建议。",
prompts: [ prompts: [
"帮我做一篇周末探店笔记,先出标题、封面字和 6 段正文结构。", "帮我做一篇推荐平价火锅的笔记",
"把这款护肤品卖点整理成一篇日常分享风的小红书笔记。", "做一篇制作抹茶奶酪欧包教程的爆文。",
"给通勤女生做一篇春季穿搭笔记,先列 4 个可拍内容点。", "给通勤女生做一篇春季穿搭笔记。"
"帮我安排一篇家居好物笔记的发布时间和评论区引导话术。"
] ]
}; };
case "douyin": case "douyin":
...@@ -1876,12 +1907,11 @@ function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent ...@@ -1876,12 +1907,11 @@ function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent
}; };
default: default:
return { return {
greeting: "说清你的目标,我会先帮你拆成可执行步骤", greeting: "说清你的目标,我会先帮你拆成可执行步骤",
summary: "适合梳理需求、生成方案、组织信息和推进执行", summary: "适合梳理需求、生成方案、组织信息和推进执行",
prompts: [ prompts: [
"先帮我梳理这个任务的目标、输入和输出。", "你好啊",
"把我的需求拆成 3 个最先执行的步骤。", "请做一个自我介绍,给出一个你的能力清单"
"根据当前专家能力,给我一个最实用的处理方案。"
] ]
}; };
} }
...@@ -1894,7 +1924,7 @@ function getExpertGuideContent(project: ExpertProject | undefined): ExpertGuideC ...@@ -1894,7 +1924,7 @@ function getExpertGuideContent(project: ExpertProject | undefined): ExpertGuideC
} }
return { return {
greeting: "先把这条抖音视频的条件说清楚,我会先帮你整理需求并给出预览。", greeting: " 先把这条抖音视频的条件说清楚,我会先帮你整理需求并给出预览。",
summary: "适合抖音视频需求补全、文案与分镜预览、数字人口播和纯画面路线判断。", summary: "适合抖音视频需求补全、文案与分镜预览、数字人口播和纯画面路线判断。",
intro: "最好一次说明主题、给谁看、想达到什么目标、风格、时长、做数字人还是纯画面,以及现在有没有图片或音频。", intro: "最好一次说明主题、给谁看、想达到什么目标、风格、时长、做数字人还是纯画面,以及现在有没有图片或音频。",
requirementChecklist: [ requirementChecklist: [
...@@ -1908,28 +1938,26 @@ function getExpertGuideContent(project: ExpertProject | undefined): ExpertGuideC ...@@ -1908,28 +1938,26 @@ function getExpertGuideContent(project: ExpertProject | undefined): ExpertGuideC
], ],
routeOptions: [ routeOptions: [
{ {
title: "有人出镜讲解", title: " 有人出镜讲解",
detail: "适合数字人口播、知识讲解、产品说明。继续生成前通常要确认人物图片,以及现成音频或男声/女声配音。", detail: "适合数字人口播、知识讲解、产品说明。继续生成前通常要确认人物图片,以及现成音频或男声/女声配音。",
accent: "host" accent: "host"
}, },
{ {
title: "纯画面展示", title: " 纯画面展示",
detail: "适合氛围片、空镜、场景展示、画面加旁白。先把视频秒数说清楚,再出文案和分镜预览。", detail: "适合氛围片、空镜、场景展示、画面加旁白。先把视频秒数说清楚,再出文案和分镜预览。",
accent: "visual" accent: "visual"
} }
], ],
workflowSteps: [ workflowSteps: [
"先生成文案和分镜预览,不直接开跑视频。", " 先生成文案和分镜预览,不直接开跑视频。",
"你确认预览后,再继续生成数字人或纯画面视频。", " 你确认预览后,再继续生成数字人或纯画面视频。",
"如果当前路线缺素材,再按需要补图片、音频或配音要求。" " 如果当前路线缺素材,再按需要补图片、音频或配音要求。"
], ],
continueHint: "看完预览后,你可以直接回复:继续生成;如果要改,直接回复:修改第几镜 + 你的修改意见。", // continueHint: "看完预览后,你可以直接回复:继续生成;如果要改,直接回复:修改第几镜 + 你的修改意见。",
placeholder: "例如:做一个讲天气预报的抖音视频,给职场人看,目标是科普,男声数字人口播,10 秒,氛围感,我会上传人物照片,先给我文案和分镜预览。", placeholder: "例如:做一个讲天气预报的抖音视频,给职场人看,目标是科普,男声数字人口播,10 秒,氛围感,我会上传人物照片,先给我文案和分镜预览。",
prompts: [ prompts: [
"做一个讲天气预报的抖音视频,给职场人看,目标是科普,男声数字人口播,10 秒,氛围感,我会上传人物照片,先给我文案和分镜预览。", "做一个海边视频不要出现人物,给附近上班族看,目标是到店引流,纯画面展示,15 秒,真实手机拍摄感",
"做一个咖啡店活动抖音视频,给附近上班族看,目标是到店引流,纯画面展示,15 秒,真实手机拍摄感,先只看文案和分镜。", "做一个护肤品种草短视频,给 25-35 岁女性看,目标是种草转化,纯画面加旁白,10 秒,精致广告感"
"做一个护肤品种草短视频,给 25-35 岁女性看,目标是种草转化,纯画面加旁白,20 秒,精致广告感,先出预览。",
"我有人物照片,没有现成音频,想做数字人口播,主题是新品发布,给职场人看,先帮我整理需求并出预览。"
] ]
}; };
} }
...@@ -2024,10 +2052,10 @@ export default function App() { ...@@ -2024,10 +2052,10 @@ export default function App() {
const [homeIntentDecisionPending, setHomeIntentDecisionPending] = useState(false); const [homeIntentDecisionPending, setHomeIntentDecisionPending] = useState(false);
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState(""); const [infoText, setInfoText] = useState("");
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>({});
const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({}); const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({});
const [messageReactions, setMessageReactions] = useState<Record<string, MessageReaction | undefined>>({}); const [messageReactions, setMessageReactions] = useState<Record<string, MessageReaction | undefined>>({});
const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({}); const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({});
const [sessionActionMenuId, setSessionActionMenuId] = useState("");
const [skillMenuOpen, setSkillMenuOpen] = useState(false); const [skillMenuOpen, setSkillMenuOpen] = useState(false);
const [isComposerDragOver, setIsComposerDragOver] = useState(false); const [isComposerDragOver, setIsComposerDragOver] = useState(false);
const [copiedToken, setCopiedToken] = useState(""); const [copiedToken, setCopiedToken] = useState("");
...@@ -2039,6 +2067,16 @@ export default function App() { ...@@ -2039,6 +2067,16 @@ export default function App() {
const startupWarmupRequestedRef = useRef(false); const startupWarmupRequestedRef = useRef(false);
const lastLoadedWorkspacePathRef = useRef<string | null>(null); const lastLoadedWorkspacePathRef = useRef<string | null>(null);
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | 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 minimizeWindow = () => void desktopApi.window.minimize();
const maximizeWindow = () => void desktopApi.window.maximize(); const maximizeWindow = () => void desktopApi.window.maximize();
const catalogSkills = workspace?.skills ?? []; const catalogSkills = workspace?.skills ?? [];
...@@ -2189,8 +2227,8 @@ export default function App() { ...@@ -2189,8 +2227,8 @@ export default function App() {
}))); })));
const workspaceStatusTone = getWorkspaceStatusTone(chatLaunchState, isBound); const workspaceStatusTone = getWorkspaceStatusTone(chatLaunchState, isBound);
const workspaceStatusLabel = getWorkspaceStatusLabel(chatLaunchState, isBound); const workspaceStatusLabel = getWorkspaceStatusLabel(chatLaunchState, isBound);
const pageTitle = viewMode === "plugins" ? ui.plugins : ui.settings; const pageTitle = viewMode === "plugins" ? ui.plugins : viewMode === "knowledge" ? ui.knowledge : ui.settings;
const pageDesc = viewMode === "plugins" ? ui.pluginsPageDesc : ui.settingsDesc; const pageDesc = viewMode === "plugins" ? ui.pluginsPageDesc : viewMode === "knowledge" ? ui.knowledgePageDesc : ui.settingsDesc;
useEffect(() => { useEffect(() => {
if (!infoText) { if (!infoText) {
return; return;
...@@ -2203,6 +2241,7 @@ export default function App() { ...@@ -2203,6 +2241,7 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [infoText]); }, [infoText]);
useEffect(() => () => { useEffect(() => () => {
if (copiedTokenResetRef.current !== null) { if (copiedTokenResetRef.current !== null) {
window.clearTimeout(copiedTokenResetRef.current); window.clearTimeout(copiedTokenResetRef.current);
...@@ -4011,7 +4050,6 @@ export default function App() { ...@@ -4011,7 +4050,6 @@ export default function App() {
function openSession(sessionId: string) { function openSession(sessionId: string) {
setViewMode((current) => (current === "experts" ? "experts" : "chat")); setViewMode((current) => (current === "experts" ? "experts" : "chat"));
setActiveSessionId(sessionId); setActiveSessionId(sessionId);
setSessionActionMenuId("");
} }
async function switchExpert(projectId: string) { async function switchExpert(projectId: string) {
...@@ -4064,11 +4102,29 @@ export default function App() { ...@@ -4064,11 +4102,29 @@ export default function App() {
return nextWorkspace; 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) { async function handleNavSelection(mode: ViewMode) {
if (mode === "chat") { if (mode === "chat") {
await openHomeChat(); await openHomeChat();
return; return;
} }
if (mode === "knowledge") {
// 知识库特定逻辑(如有)
}
setViewMode(mode); setViewMode(mode);
} }
...@@ -4136,11 +4192,11 @@ export default function App() { ...@@ -4136,11 +4192,11 @@ export default function App() {
const sidebarNewSessionAction = ( const sidebarNewSessionAction = (
<button <button
type="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} disabled={projectActionPending || !isBound || !projects.length}
onClick={() => void createProjectSession()} 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> <span className="conversation-new-session-label">新对话</span>
</button> </button>
); );
...@@ -4159,7 +4215,7 @@ export default function App() { ...@@ -4159,7 +4215,7 @@ export default function App() {
{renderExpertIcon(activeExpertVisualKey)} {renderExpertIcon(activeExpertVisualKey)}
</span> </span>
<span className="expert-hero-copy"> <span className="expert-hero-copy">
<span className="expert-hero-label">当前专家</span> {/* <span className="expert-hero-label">当前专家</span> */}
<strong>{conversationPanelTitle}</strong> <strong>{conversationPanelTitle}</strong>
</span> </span>
</div> </div>
...@@ -4228,7 +4284,7 @@ export default function App() { ...@@ -4228,7 +4284,7 @@ export default function App() {
</section> </section>
) : null} ) : null}
<div className="douyin-guide-footer"> <div className="douyin-guide-footer">
<span className="douyin-guide-label">可以直接这样发</span> {/* <span className="douyin-guide-label">可以直接这样发</span> */}
<p>{ui.starterQuestionsHint}</p> <p>{ui.starterQuestionsHint}</p>
<div className="starter-prompt-list"> <div className="starter-prompt-list">
{activeExpertGuide.prompts.map((item) => ( {activeExpertGuide.prompts.map((item) => (
...@@ -4253,7 +4309,6 @@ export default function App() { ...@@ -4253,7 +4309,6 @@ export default function App() {
<div className="empty-state home-empty-state"> <div className="empty-state home-empty-state">
<span className="empty-state-kicker">{selectedSkillBadge}</span> <span className="empty-state-kicker">{selectedSkillBadge}</span>
<strong>{homeChatCopy.emptyTitle}</strong> <strong>{homeChatCopy.emptyTitle}</strong>
<p>{homeChatCopy.emptyDesc}</p>
<div className="starter-prompt-list"> <div className="starter-prompt-list">
{homeChatCopy.prompts.map((item) => ( {homeChatCopy.prompts.map((item) => (
<button key={item} type="button" className="starter-prompt" onClick={() => applyStarterPrompt(item)}>{item}</button> <button key={item} type="button" className="starter-prompt" onClick={() => applyStarterPrompt(item)}>{item}</button>
...@@ -4263,7 +4318,7 @@ export default function App() { ...@@ -4263,7 +4318,7 @@ export default function App() {
); );
const messageListContent = ( 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) => { {messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim(); const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null; const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null;
...@@ -4274,8 +4329,8 @@ export default function App() { ...@@ -4274,8 +4329,8 @@ export default function App() {
const copyToken = `message:${message.id}`; const copyToken = `message:${message.id}`;
const reaction = messageReactions[message.id]; const reaction = messageReactions[message.id];
return ( 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")}> <article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}>
<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)]")}> <div className={"message-bubble" + (message.role === "assistant" ? " message-bubble-assistant" : " message-bubble-user")}>
{showThinking ? ( {showThinking ? (
videoStatusCard ? ( videoStatusCard ? (
<div className="generation-status-card" aria-live="polite"> <div className="generation-status-card" aria-live="polite">
...@@ -4299,7 +4354,7 @@ export default function App() { ...@@ -4299,7 +4354,7 @@ export default function App() {
) )
) : message.content ? ( ) : message.content ? (
message.role === "assistant" ? ( message.role === "assistant" ? (
<div className="markdown-body !gap-4 text-[15px] leading-8 text-[#0f172a]"> <div className="markdown-body">
{renderMarkdownContent(message.content, { {renderMarkdownContent(message.content, {
messageId: message.id, messageId: message.id,
copiedToken, copiedToken,
...@@ -4308,7 +4363,7 @@ export default function App() { ...@@ -4308,7 +4363,7 @@ export default function App() {
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null} {message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</div> </div>
) : ( ) : (
<p className="message-plain-text !m-0 text-[15px] leading-8 text-[#0f172a]"> <p className="message-plain-text">
{message.content} {message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null} {message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p> </p>
...@@ -4334,10 +4389,10 @@ export default function App() { ...@@ -4334,10 +4389,10 @@ export default function App() {
) : null} ) : null}
</div> </div>
{message.role === "assistant" && canCopyMessage ? ( {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 <button
type="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)} onClick={() => void handleCopyText(copyToken, message.content)}
aria-label="复制消息" aria-label="复制消息"
title="复制消息" title="复制消息"
...@@ -4431,7 +4486,12 @@ export default function App() { ...@@ -4431,7 +4486,12 @@ export default function App() {
const composerContent = ( const composerContent = (
<form <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) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
void sendPrompt(); void sendPrompt();
...@@ -4449,83 +4509,80 @@ export default function App() { ...@@ -4449,83 +4509,80 @@ export default function App() {
tabIndex={-1} tabIndex={-1}
onChange={handleAttachmentSelection} onChange={handleAttachmentSelection}
/> />
{isComposerDragOver ? <div className="composer-drop-indicator !min-h-14 !rounded-[18px] !border-dashed !border-[#93c5fd] !bg-[#f0f7ff] !text-[#2563eb]">释放以上传图片</div> : null} {isComposerDragOver ? <div className="composer-drop-indicator">释放以上传图片</div> : null}
<label className="composer-field !gap-0"> <div className="composer-surface">
<textarea <label className="composer-field">
value={prompt} <textarea
disabled={!isBound} value={prompt}
onChange={(event) => setPrompt(event.target.value)} disabled={!isBound}
onKeyDown={(event) => void handleComposerKeyDown(event)} onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder} onKeyDown={(event) => void handleComposerKeyDown(event)}
className="!min-h-[60px] !rounded-none !border-0 !bg-transparent !p-0 !text-[15px] !leading-8 !text-[#0f172a] placeholder:!text-transparent" placeholder={composerPlaceholder}
/> className="composer-textarea"
</label> />
{composerAttachment ? ( </label>
<div className="composer-attachment-strip !mt-0"> {composerAttachment ? (
<span className="composer-attachment-chip !rounded-full !border !border-[#d7e8ff] !bg-[#f0f7ff] !px-3 !py-2"> <div className="composer-attachment-strip">
<span className="composer-attachment-chip-label">{composerAttachment.name}</span> <span className="composer-attachment-chip">
<button type="button" className="composer-attachment-remove" onClick={() => clearComposerAttachment()} aria-label="移除图片附件"> <span className="composer-attachment-chip-label">{composerAttachment.name}</span>
x <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> </button>
</span> <button type="button" className="skill-trigger" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
</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> </button>
) : null} {selectedSkillId !== DEFAULT_SKILL.id ? (
<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)}> <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> </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> </div>
<button <p className="composer-hint">按 Enter 发送,Shift + Enter 换行</p>
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>
</div> </div>
<p className="composer-hint !m-0 !text-[11px] !text-[#94a3b8]">按 Enter 发送,Shift + Enter 换行</p>
</form> </form>
); );
return ( 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="窗口控制"> <div className="window-controls" aria-label="窗口控制">
<button type="button" className="window-control-button" aria-label="最小化窗口" onClick={minimizeWindow}> <button type="button" className="window-control-button" aria-label="最小化窗口" onClick={minimizeWindow}>
<WindowControlIcon kind="minimize" /> <WindowControlIcon kind="minimize" />
...@@ -4537,9 +4594,9 @@ export default function App() { ...@@ -4537,9 +4594,9 @@ export default function App() {
<WindowControlIcon kind="close" /> <WindowControlIcon kind="close" />
</button> </button>
</div> </div>
<aside className="sidebar app-drag-region !w-[280px] !border-r !border-[#dbeafe] !bg-[#f9fbff] !px-4 !py-5"> <aside className={"sidebar app-drag-region" + (isConversationView ? " conversation-sidebar-layout" : "")}>
<div className="sidebar-top !gap-4"> <div className="sidebar-top">
<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="千匠问天"> <div className="sidebar-logo-block" aria-label="千匠问天">
<div className="sidebar-logo-mark-shell" aria-hidden="true"> <div className="sidebar-logo-mark-shell" aria-hidden="true">
<img src={brandIcon} alt="" className="sidebar-logo-mark" /> <img src={brandIcon} alt="" className="sidebar-logo-mark" />
</div> </div>
...@@ -4547,14 +4604,15 @@ export default function App() { ...@@ -4547,14 +4604,15 @@ export default function App() {
<strong>千匠问天</strong> <strong>千匠问天</strong>
</div> </div>
</div> </div>
<nav className="nav-list !gap-2"> <nav className="nav-list">
{[ {[
{ id: "chat" as const, label: "对话" }, { 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: "plugins" as const, label: ui.plugins },
{ id: "settings" as const, label: ui.settings } { id: "settings" as const, label: ui.settings }
].map((item) => ( ].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"> <span className="nav-item-icon" aria-hidden="true">
<NavIcon kind={item.id} /> <NavIcon kind={item.id} />
</span> </span>
...@@ -4564,83 +4622,118 @@ export default function App() { ...@@ -4564,83 +4622,118 @@ export default function App() {
</nav> </nav>
{!showBindEntry ? sidebarNewSessionAction : null} {!showBindEntry ? sidebarNewSessionAction : null}
</div> </div>
<div className="sidebar-bottom !gap-4"> <div className="sidebar-bottom">
<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)]"> <section className="sidebar-section compact sidebar-experts-entry">
{sidebarExpertEntries.length ? ( <div className="sidebar-expert-scroll">
<div className="sidebar-expert-scroll"> <div className="expert-category-list">
<div className="expert-chip-list preview !gap-2"> {CATEGORY_CONFIG.map((category) => {
{sidebarExpertEntries.map((entry) => { // 获取该分类的专家
const expertVisualKey = resolveExpertVisualKey(entry.project, entry.definition); const categoryExperts = sidebarExpertEntries.filter(entry => {
const isStandalone = entry.definition.entryMode === "standalone"; const expertId = entry.definition.id;
const isActive = isStandalone const expertName = entry.displayName;
? viewMode === "experts" && entry.project?.id === activeProject?.id const expertSeed = (expertId + ' ' + expertName).toLowerCase();
: viewMode === "chat" && prompt.trim() === buildShortcutPrompt(entry.definition);
return ( // 根据分类ID进行匹配
<button if (category.id === 'content') {
key={entry.definition.id} // 内容营销:小红书、抖音、内容账号规划、知乎
type="button" return /xiaohongshu|xhs|rednote|小红书|douyin|抖音|content-account|内容账号规划|zhihu|知乎/.test(expertSeed);
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")} } else if (category.id === 'acquisition') {
disabled={projectActionPending || !entry.isAvailable} // 精准获客:GEO、平台精准线索
onClick={() => { return /geo|precision-leads|精准线索|线索/.test(expertSeed);
if (isStandalone) { } else if (category.id === 'sales') {
if (entry.project) { // 销售冠军
void switchExpert(entry.project.id); return /sales-champion|销售冠军|销冠/.test(expertSeed);
} else { } else if (category.id === 'other') {
setErrorText(`${entry.displayName} 项目未下发`); // 其他专家:不匹配以上分类的专家
} return !(
return; /xiaohongshu|xhs|rednote|小红书|douyin|抖音|content-account|内容账号规划|zhihu|知乎/.test(expertSeed) ||
} /geo|precision-leads|精准线索|线索/.test(expertSeed) ||
void activateHomeShortcut(entry); /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"> <div className="expert-category-icon">{category.icon}</div>
{renderExpertIcon(expertVisualKey)} <div className="expert-category-title">
</span> <div className="expert-category-name">{category.name}</div>
<span className="expert-chip-copy">{entry.displayName}</span> </div>
</button> <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"/>
</div> </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> </div>
) : null} </div>
</section> </section>
{!showBindEntry ? ( {!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-head sidebar-section-head-subtle">
<div className="sidebar-section-copy"> <div className="sidebar-section-copy">
<span className="sidebar-section-label">{sidebarSessionLabel}</span> <span className="sidebar-section-label">{sidebarSessionLabel}</span>
</div> </div>
</div> </div>
<div className="sidebar-session-list !gap-2"> <div className="sidebar-session-list">
{sessions.map((session, index) => ( {sessions.map((session, index) => (
<div key={session.id} className={"sidebar-session-card !rounded-[16px]" + (activeSessionId === session.id ? " active !bg-[#f0f7ff]" : " hover:!bg-[#f8fbff]")}> <div key={session.id} className={"sidebar-session-card" + (activeSessionId === session.id ? " active" : "")}>
<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)}> <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> <strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button> </button>
{sessions.length > 1 ? ( {sessions.length > 1 ? (
<div className="sidebar-session-actions"> <button
<button type="button"
type="button" className="sidebar-session-close app-no-drag"
className={"sidebar-session-close" + (sessionActionMenuId === session.id ? " active" : "")} aria-label={ui.closeSession}
aria-label="会话操作" title={ui.closeSession}
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)} disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => setSessionActionMenuId((current) => current === session.id ? "" : session.id)} onClick={() => void closeProjectSession(session.id)}
> >
<MoreIcon /> x
</button> </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>
) : null} ) : null}
</div> </div>
))} ))}
...@@ -4649,8 +4742,8 @@ export default function App() { ...@@ -4649,8 +4742,8 @@ export default function App() {
) : null} ) : null}
</div> </div>
</aside> </aside>
<div className="main-shell !bg-[#f0f7ff]"> <div className={"main-shell" + (isConversationView ? " conversation-main-layout" : "")}>
{!isConversationView ? ( {!isConversationView && viewMode !== "knowledge" ? (
<div className="page-topbar"> <div className="page-topbar">
<div className="page-copy"> <div className="page-copy">
<h2>{pageTitle}</h2> <h2>{pageTitle}</h2>
...@@ -4665,182 +4758,267 @@ export default function App() { ...@@ -4665,182 +4758,267 @@ export default function App() {
) : null} ) : null}
{infoText ? <div className="notice toast-notice">{infoText}</div> : null} {infoText ? <div className="notice toast-notice">{infoText}</div> : null}
{errorText ? <div className="notice error">{errorText}</div> : null} {errorText ? <div className="notice error">{errorText}</div> : null}
<main className="content-area"> <main className={"content-area" + (isConversationView ? " conversation-content-area" : "")}>
{isConversationView ? ( {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" : "")}> <section className="chat-panel conversation-panel">
<div className="conversation-panel-head conversation-panel-head-layout app-drag-region !grid !items-center !gap-4 !border-0 !pb-0"> <div className="conversation-panel-head conversation-panel-head-layout app-drag-region">
<div className="conversation-panel-copy"> <div className="conversation-panel-copy">
{conversationPanelLead} {conversationPanelLead}
</div> </div>
<div className="conversation-drag-strip" aria-hidden="true" /> <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> <StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip>
{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null} {isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}
</div> </div>
</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} {conversationStatusNotice}
{viewMode === "chat" ? homeIntentSuggestionNotice : null} {viewMode === "chat" ? homeIntentSuggestionNotice : null}
{conversationBodyContent} <div className="conversation-panel-body">
{conversationBodyContent}
</div>
{composerContent}
</div> </div>
{composerContent}
</section> </section>
) : null} ) : null}
{viewMode === "plugins" ? ( {viewMode === "plugins" ? (
<section className="panel catalog-list plugin-page"> <div className="page-stack plugin-page-stack">
<div className="scroll-panel plugin-section-list plugin-flat-list"> <section className="panel plugin-page">
<div className="plugin-grid plugin-flat-grid"> <div className="scroll-panel plugin-section-list plugin-flat-list">
{pluginCards.map(({ plugin, sectionTitle }) => { <div className="plugin-grid plugin-flat-grid">
const copy = getPluginCopy(plugin); {pluginCards.map(({ plugin, sectionTitle }) => {
return ( const copy = getPluginCopy(plugin);
<article key={plugin.id} className="catalog-item static plugin-card plugin-flat-card"> return (
<div className="plugin-card-glow" aria-hidden="true" /> <article key={plugin.id} className="catalog-item static plugin-card plugin-flat-card">
<div className="plugin-card-claw" aria-hidden="true"> <div className="plugin-card-glow" aria-hidden="true" />
<LobsterClawIcon /> <div className="plugin-card-claw" aria-hidden="true">
</div> <LobsterClawIcon />
<div className="plugin-card-meta"> </div>
<span className="plugin-card-group">{sectionTitle}</span> <div className="plugin-card-meta">
<StatusChip tone={getPluginTone(plugin.status)}>{getPluginStatusLabel(plugin.status)}</StatusChip> <span className="plugin-card-group">{sectionTitle}</span>
</div> <StatusChip tone={getPluginTone(plugin.status)}>{getPluginStatusLabel(plugin.status)}</StatusChip>
<div className="plugin-card-head"> </div>
<strong>{copy.name}</strong> <div className="plugin-card-head">
</div> <strong>{copy.name}</strong>
<p>{copy.description}</p> </div>
</article> <p>{copy.description}</p>
); </article>
})} );
})}
</div>
{!pluginCards.length ? <div className="empty-state">{ui.noPlugins}</div> : null}
</div> </div>
{!pluginCards.length ? <div className="empty-state">{ui.noPlugins}</div> : null} </section>
</div> </div>
</section>
) : null} ) : null}
{viewMode === "settings" ? ( {viewMode === "knowledge" ? (
<div className="page-stack settings-page-stack"> <div className="page-stack knowledge-page-stack">
{showSettingsStatusHint ? <div className={"inline-hint settings-runtime-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null} <section className="panel knowledge-panel">
<section className="panel settings-panel settings-panel-hero compact settings-panel-modern"> <div className="knowledge-header">
<div className="settings-section-card"> <h1 className="knowledge-title">企业知识库</h1>
<div className="settings-section-headline"> <p className="knowledge-subtitle">上传和管理您的企业知识文档</p>
<div> </div>
<span className="settings-section-kicker">基础连接</span>
<h4>客户端绑定</h4> <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> </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>
<div className="settings-field-grid single"> </div>
<label className="settings-input-label">
<span className="settings-input-label-text">员工密钥</span> <div className="knowledge-list-section">
<input <h2 className="section-title">最近文档</h2>
type="password" <div className="document-grid">
value={lobsterKeyDraft} {/* 文档列表占位符 */}
placeholder={workspace?.apiKeyConfigured ? "输入新的龙虾密钥以更新绑定" : "请输入龙虾密钥"} <div className="document-card">
onChange={(event) => setLobsterKeyDraft(event.target.value)} <div className="document-icon">📄</div>
/> <div className="document-info">
</label> <h4>产品手册.pdf</h4>
</div> <p>更新于 2 小时前 · 2.4 MB</p>
<div className="button-row settings-actions"> </div>
<button disabled={saving || !hasPendingLobsterKey} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button> </div>
</div> </div>
</div> </div>
<div className="settings-section-card"> </section>
<div className="settings-section-headline"> </div>
<div> ) : null}
<span className="settings-section-kicker">专家模型配置</span> {viewMode === "settings" ? (
<h4>按能力分别配置</h4> <div className="page-stack settings-page-stack settings-page-shell">
<p>固定参数已内置,只保留客户需要填写的密钥。</p> {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> </div>
<div className="model-config-grid model-config-grid-four"> </section>
<article className="model-config-card"> <section className="panel settings-panel settings-panel-secondary settings-panel-diagnostics">
<div className="model-config-card-head"> <div className="settings-section-card settings-section-card-compact">
<div> <div className="settings-section-headline">
<strong>文案模型</strong> <div>
<p>用于标题、脚本、口播稿与内容润色。</p> <span className="settings-section-kicker">诊断与工作区</span>
</div> {/* <h4>当前生效目录</h4> */}
<StatusChip tone={config?.expertModelConfig.copywriting.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.copywriting.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div> </div>
<div className="settings-field-grid single"> <StatusChip tone={hasPendingWorkspacePathChange ? "warning" : "info"}>{hasPendingWorkspacePathChange ? "待保存" : "已同步"}</StatusChip>
<label className="settings-input-label"> </div>
<span className="settings-input-label-text">API Key</span> <div className="workspace-directory-card">
<input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} /> <div className="workspace-directory-panel">
</label> <span className="workspace-directory-eyebrow">当前生效目录</span>
<strong className="workspace-directory-path">{displayedWorkspacePath}</strong>
{/* <p className="workspace-directory-hint">项目会从该目录加载;保存后会重新预热工作区。</p> */}
</div> </div>
</article> {hasPendingWorkspacePathChange ? (
<article className="model-config-card"> <div className="workspace-directory-draft-row">
<div className="model-config-card-head"> <span className="workspace-directory-draft-badge">待保存</span>
<div> <div className="workspace-directory-inline-actions">
<strong>生图模型</strong> <button disabled={saving} onClick={() => void saveWorkspaceDirectory()}>{saving ? ui.saving : ui.save}</button>
<p>用于封面草图、画面创意和视觉素材生成。</p> <button className="secondary workspace-directory-inline-button" disabled={saving} onClick={restoreWorkspaceDirectory}>恢复当前</button>
</div>
</div> </div>
<StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip> ) : null}
</div> </div>
<div className="settings-field-grid single"> <div className="button-row settings-actions workspace-directory-actions">
<label className="settings-input-label"> <button className="settings-primary-button" disabled={saving || !config} onClick={() => void pickWorkspaceDirectory()}>更改目录</button>
<span className="settings-input-label-text">API Key</span> <button className="secondary" disabled={saving} onClick={() => void exportDiagnostics()}>{ui.export}</button>
<input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} /> </div>
</label> </div>
</div> </section>
</article> <section className="panel settings-panel settings-panel-models">
<article className="model-config-card"> <div className="settings-section-card settings-section-card-models">
<div className="model-config-card-head"> <div className="settings-section-headline settings-section-headline-minimal">
<div> <span className="settings-section-kicker">专家模型配置</span>
<strong>普通视频模型</strong> </div>
<p>用于纯画面视频生成,客户只需要配置一个 Key。</p> <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> </div>
<StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip> <div className="model-config-card-body">
</div> <div className="settings-field-grid single">
<div className="settings-field-grid single"> <label className="settings-input-label">
<label className="settings-input-label"> {/* <span className="settings-input-label-text">API Key</span> */}
<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)} />
<input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} /> </label>
</label> </div>
</div>
</article>
<article className="model-config-card">
<div className="model-config-card-head">
<div>
<strong>数字人配置</strong>
<p>用于数字人口播视频,火山与七牛的四个 Key 由客户填写。</p>
</div> </div>
<StatusChip tone={ </article>
config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured <article className="model-config-card model-config-card-image">
&& config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured <div className="model-config-card-head">
&& config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured <div>
&& config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured <strong>生图模型</strong>
? "positive" <p>用于封面草图、画面创意和视觉素材生成</p>
: "warning" </div>
}> <StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
{config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured </div>
&& config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured <div className="model-config-card-body">
&& config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured <div className="settings-field-grid single">
&& config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured <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)} />
</StatusChip> </label>
</div> </div>
<div className="settings-field-grid"> </div>
<label className="settings-input-label"> </article>
<span className="settings-input-label-text">VOLC_ACCESS_KEY</span> <article className="model-config-card model-config-card-video">
<input type="password" value={digitalHumanVolcAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"} onChange={(event) => setDigitalHumanVolcAccessKeyDraft(event.target.value)} /> <div className="model-config-card-head">
</label> <div>
<label className="settings-input-label"> <strong>普通视频模型</strong>
<span className="settings-input-label-text">VOLC_SECRET_KEY</span> <p>用于纯画面视频生成</p>
<input type="password" value={digitalHumanVolcSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"} onChange={(event) => setDigitalHumanVolcSecretKeyDraft(event.target.value)} /> </div>
</label> <StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
<label className="settings-input-label"> </div>
<span className="settings-input-label-text">QINIU_ACCESS_KEY</span> <div className="model-config-card-body">
<input type="password" value={digitalHumanQiniuAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"} onChange={(event) => setDigitalHumanQiniuAccessKeyDraft(event.target.value)} /> <div className="settings-field-grid single">
</label> <label className="settings-input-label">
<label className="settings-input-label"> {/* <span className="settings-input-label-text">API Key</span> */}
<span className="settings-input-label-text">QINIU_SECRET_KEY</span> <input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} />
<input type="password" value={digitalHumanQiniuSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"} onChange={(event) => setDigitalHumanQiniuSecretKeyDraft(event.target.value)} /> </label>
</label> </div>
</div> </div>
</article> </article>
</div> <article className="model-config-card model-config-card-digital-human">
<div className="button-row settings-actions"> <div className="model-config-card-head">
<button disabled={saving || !hasPendingModelKeys} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button> <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>
</div> </section>
</section> </div>
{false ? ( {false ? (
<section className="panel settings-panel settings-panel-hero"> <section className="panel settings-panel settings-panel-hero">
<div className="section-head compact settings-hero-status-row"> <div className="section-head compact settings-hero-status-row">
...@@ -4952,35 +5130,6 @@ export default function App() { ...@@ -4952,35 +5130,6 @@ export default function App() {
{showSettingsStatusHint ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null} {showSettingsStatusHint ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
</section> </section>
) : null} ) : 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> </div>
) : null} ) : null}
</main> </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