diff --git a/frontend-web/public/agent-avatar.png b/frontend-web/public/agent-avatar.png new file mode 100644 index 00000000..511ec5c4 Binary files /dev/null and b/frontend-web/public/agent-avatar.png differ diff --git a/frontend-web/public/cm-assistant-logo.png b/frontend-web/public/cm-assistant-logo.png new file mode 100644 index 00000000..9051a83a Binary files /dev/null and b/frontend-web/public/cm-assistant-logo.png differ diff --git a/frontend-web/public/cm-assistant-logo.svg b/frontend-web/public/cm-assistant-logo.svg new file mode 100644 index 00000000..336c309a --- /dev/null +++ b/frontend-web/public/cm-assistant-logo.svg @@ -0,0 +1,28 @@ + + CM 助手 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend-web/src/components/workspace/agents/agent-brand-icon.tsx b/frontend-web/src/components/workspace/agents/agent-brand-icon.tsx new file mode 100644 index 00000000..4a82bf61 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-brand-icon.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { BotIcon, SparklesIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +type AgentBrandIconProps = { + className?: string; + size?: "nav" | "sm" | "md" | "lg"; +}; + +const SIZE_STYLES = { + nav: { + frame: "size-[22px] rounded-md", + bot: "size-3.5", + badge: "-right-1 -top-1 size-2.5", + sparkle: "size-1.5", + }, + sm: { + frame: "size-10 rounded-full", + bot: "size-[18px]", + badge: "-right-1 -top-1 size-3.5", + sparkle: "size-2", + }, + md: { + frame: "size-12 rounded-full", + bot: "size-6", + badge: "-right-1 -top-1 size-4", + sparkle: "size-2.5", + }, + lg: { + frame: "size-16 rounded-full", + bot: "size-7", + badge: "-right-1 -top-1 size-[18px]", + sparkle: "size-3", + }, +} as const; + +export function AgentBrandIcon({ + className, + size = "md", +}: AgentBrandIconProps) { + const styles = SIZE_STYLES[size]; + const showBadge = size === "nav"; + + if (size !== "nav") { + return ( + + + + ); + } + + return ( + + + {showBadge && ( + + + + )} + + ); +} diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 006f2811..62870b9a 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -1,13 +1,13 @@ "use client"; -import { - BotIcon, +import { ClockIcon, CopyIcon, EyeOffIcon, GlobeIcon, LockIcon, - MessageSquareIcon, + MessageSquareIcon, + MoreHorizontalIcon, PinIcon, ShieldIcon, SparklesIcon, @@ -28,7 +28,7 @@ import { } from "react"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -38,14 +38,21 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - Dialog, +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Select, @@ -59,7 +66,8 @@ import { Textarea } from "@/components/ui/textarea"; import { usePublishSquares } from "@/core/system-settings/hooks"; import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; -import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; +import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; +import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon"; import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector"; import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; @@ -548,10 +556,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar 内置 )} - {isOwned && ( - 我的 @@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar {isFromOthers && ( 来自他人 @@ -568,7 +576,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar {(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && ( @@ -663,8 +671,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar const skillBadgeItems = [ ...(agent.tool_groups ?? []).map((group) => ({ key: `tg:${group}`, - node: ( - + node: ( + {desensitize(group)} ), @@ -674,8 +686,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar node: ( { e.stopPropagation(); @@ -823,80 +835,181 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar > ); - const chatButton = ( - - {t.agents.chat} + 开始对话 - ); + ); + + const cardStatusBadge = agent.is_private ? ( + + + 私密 + + ) : agent.published || isBuiltin ? ( + + + 已发布 + + ) : ( + + + 运行中 + + ); + + const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at); + const cardActionsMenu = ( + + + + + + + + {canFavorite && ( + void handleToggleFavorite()}> + + {isFavorite ? "取消收藏" : "收藏到我的"} + + )} + void handleTogglePin()}> + + {isPinned ? "取消置顶" : "置顶"} + + {isAdmin && ( + void handleToggleFeatured()}> + + {agent.featured_order != null ? "取消推荐" : "设为推荐"} + + )} + setTagPickerOpen(true)}> + + {t.adminTags.manageTags} + + {isOwned && ( + void handleTogglePublished()}> + {agent.published ? : } + {agent.published ? "取消发布" : "发布"} + + )} + {(isOwned || isAdmin) && ( + void handleTogglePrivate()}> + + {agent.is_private ? "移出私密广场" : "放入私密广场"} + + )} + + void handleDuplicate()}> + + 复制智能体 + + {canEdit && ( + setEditOpen(true)}> + + 编辑智能体 + + )} + {canDelete && ( + <> + + setDeleteOpen(true)} + > + + {t.agents.delete} + + > + )} + + + ); - const cardLayout = ( - - - - - - - - - - {desensitize(agent.name)} - - {agent.model && ( - - {desensitize(agent.model)} - - )} - - - - {statusBadges} - - - {agent.description && ( - - {desensitize(agent.description)} - - )} - {publisherNode && {publisherNode}} - {createdAtNode && {createdAtNode}} - - - {(agent.tags?.length ?? 0) > 0 && ( - - + const cardLayout = ( + + + {agent.featured_order != null ? ( + <> + + + + 推荐 + + {cardActionsMenu} + + + > + ) : ( + + + {cardActionsMenu} + + )} + + {desensitize(agent.name)} + + {agent.description && ( + + {desensitize(agent.description)} + + )} + + + {(agent.tags?.length ?? 0) > 0 && ( + + )} {hasSkillsOrTools && ( - - - - )} - - - {showActionRow && ( - - {actionButtons} - - )} - {chatButton} - + + + + )} + + + + {cardStatusBadge} + + {updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"} + {publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""} + + + {chatButton} + ); const listLayout = ( - - - - + + - - - + {editing && draft ? ( ("mine"); const [search, setSearch] = useState(""); - const [tagIds, setTagIds] = useState([]); const [sort, setSort] = useState("time_desc"); + const [statusFilter, setStatusFilter] = useState("all"); const { data: publishSquares } = usePublishSquares(); // Each configured publish square (发布广场) becomes its own browse tab; the // backend always returns at least one (the built-in default). @@ -53,10 +61,9 @@ export function AgentGallery() { // frontend's desensitize map, so typing the displayed abbreviation (e.g. // "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the // filters change, every tab returns to its first page. - const tagKey = tagIds.join(","); useEffect(() => { setPages({}); - }, [search, tagKey, sort, publisher?.id]); + }, [search, sort, publisher?.id, statusFilter]); // If the active square tab disappears (admin removed that square), fall back // to "我的" so the gallery never points at a dead tab. @@ -108,7 +115,6 @@ export function AgentGallery() { } = useAgentsPaginated({ scope: mainScope, search, - tagIds, sort, page: mainPage, pageSize: PAGE_SIZE, @@ -126,7 +132,6 @@ export function AgentGallery() { error: privateError, } = usePrivateAgentsPaginated(privatePassword, { search, - tagIds, sort, page: pages.private ?? 1, pageSize: PAGE_SIZE, @@ -177,21 +182,22 @@ export function AgentGallery() { const handlePublisherClick = (p: { id: string; label: string }) => setPublisher(p); - const renderGrid = (list: Agent[]) => - viewMode === "list" ? ( - - {list.map((agent) => ( - - ))} - - ) : ( - - {list.map((agent) => ( + const filterByStatus = (agent: Agent) => { + if (statusFilter === "published") return agent.published && !agent.is_private; + if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin; + if (statusFilter === "private") return agent.is_private; + if (statusFilter === "builtin") return agent.builtin; + return true; + }; + + const renderGrid = (list: Agent[]) => { + const visibleAgents = list.filter(filterByStatus); + if (visibleAgents.length === 0 && list.length > 0) { + return renderEmpty("当前状态筛选下没有智能体。"); + } + return ( + + {visibleAgents.map((agent) => ( ); + }; /** Loading / empty / (grid + pagination) for one paginated tab. */ const renderListBody = (opts: { @@ -264,10 +271,8 @@ export function AgentGallery() { }; const renderEmpty = (description: string, showNewButton = false) => ( - - - - + + {t.agents.emptyTitle} @@ -276,8 +281,7 @@ export function AgentGallery() { {showNewButton && ( @@ -364,17 +368,22 @@ export function AgentGallery() { }; return ( - + {/* Page header */} - + - {t.agents.title} - - {t.agents.description} + + {t.agents.title} + + + 创建并管理专属的 AI 智能体,助力更高效的工作与决策。 - - + + {t.agents.newAgent} @@ -384,43 +393,99 @@ export function AgentGallery() { value={activeTab} onValueChange={(v) => { setActiveTab(v); - // Each tab starts from a clean slate: switching tabs clears the - // keyword / tag / sort / publisher filters instead of carrying them - // over. The TagSearchBar's own input box is reset via its key below. + // Each tab starts from a clean slate instead of carrying filters over. setSearch(""); - setTagIds([]); setSort("time_desc"); + setStatusFilter("all"); setPublisher(null); }} className="flex min-h-0 flex-1 flex-col overflow-hidden" > - - - - 我的 + + + + + 我的 + {squareTabs.map((sq) => ( - + {sq.name} ))} {privateFeatureAvailable && ( - + 私密广场 )} {isAdmin && ( - + 内置智能体 )} - - - 共 {activeTotal} 个 - - + {showSearchBar && ( + + + + setSearch(event.target.value)} + placeholder="搜索智能体名称或描述" + className="h-12 rounded-xl border-slate-200 bg-white pl-11 text-sm shadow-none placeholder:text-slate-400 focus-visible:border-blue-400 focus-visible:ring-blue-100 dark:border-slate-700 dark:bg-slate-900" + /> + {(activeTab === "private" ? isPrivateFetching : mainFetching) && ( + + )} + + setStatusFilter(value as AgentStatusFilter)} + > + + + + + 全部状态 + 已发布 + 未发布 + 私密 + 内置 + + + setSort(v as AgentSort)}> + + + + + + 更新时间 + 最早创建 + 最近更新 + 最早更新 + 名称 A-Z + 名称 Z-A + + {activeTab === "private" && privatePassword && ( )} - + + )} - {showSearchBar && ( - setSort(v as AgentSort)}> - - - - - - 最新创建 - 最早创建 - 最近更新 - 最早更新 - 名称 A-Z - 名称 Z-A - - - } - /> - )} {publisher && ( - + 筛选发布者: setPublisher(null)} - className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors" + className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-300" title="点击清除发布者筛选" > {publisher.label} @@ -481,13 +514,16 @@ export function AgentGallery() { )} + + 共 {activeTotal} 个智能体 + {squareTabs.map((sq) => ( {renderListBody({ list: mainAgents, @@ -500,7 +536,7 @@ export function AgentGallery() { ))} - + {renderListBody({ list: mainAgents, total: mainTotal, @@ -513,7 +549,7 @@ export function AgentGallery() { {isAdmin && ( - + {renderListBody({ list: mainAgents, total: mainTotal, @@ -528,7 +564,7 @@ export function AgentGallery() { {privateFeatureAvailable && ( {renderPrivateTab()} diff --git a/frontend-web/src/components/workspace/cm-chat-sidebar.tsx b/frontend-web/src/components/workspace/cm-chat-sidebar.tsx new file mode 100644 index 00000000..6aa80501 --- /dev/null +++ b/frontend-web/src/components/workspace/cm-chat-sidebar.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { + BookOpenCheck, + Brain, + CalendarClock, + MessageSquare, + MessageSquarePlus, + PanelLeftClose, + PenLine, + Settings, + Sparkles, + UsersRound, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { MouseEvent } from "react"; +import { useNavigate } from "react-router-dom"; + +import { cn } from "@/lib/utils"; + +import { AgentBrandIcon } from "./agents/agent-brand-icon"; +import { RecentChatList } from "./recent-chat-list"; +import { WorkspaceNavMenu } from "./workspace-nav-menu"; + +const NEW_CHAT_PATH = "/page/workspace/chats/new"; + +type NavItem = { + label: string; + href: string; + icon: typeof MessageSquare; + match: string; +}; + +const NAV_ITEMS: NavItem[] = [ + { label: "新建对话", href: NEW_CHAT_PATH, icon: MessageSquarePlus, match: NEW_CHAT_PATH }, + { label: "对话", href: "/page/workspace/chats", icon: MessageSquare, match: "/page/workspace/chats" }, + { label: "智能体", href: "/page/workspace/agents", icon: Sparkles, match: "/page/workspace/agents" }, + { label: "技能", href: "/page/workspace/skills", icon: Sparkles, match: "/page/workspace/skills" }, + { label: "记忆", href: "/page/workspace/memory", icon: Brain, match: "/page/workspace/memory" }, + { label: "AI 写作", href: "/page/workspace/ai-writing", icon: PenLine, match: "/page/workspace/ai-writing" }, + { + label: "多智能体规划", + href: "/page/workspace/roundtable/planning", + icon: UsersRound, + match: "/page/workspace/roundtable", + }, + { + label: "定时任务", + href: "/page/workspace/scheduled/tasks", + icon: CalendarClock, + match: "/page/workspace/scheduled", + }, + { label: "问答梳理", href: "/page/workspace/knowledge", icon: BookOpenCheck, match: "/page/workspace/knowledge" }, +] as const; + +export function CmChatSidebar({ + onCollapse, + collapsed = false, +}: { + onCollapse?: () => void; + collapsed?: boolean; +}) { + const pathname = usePathname(); + const navigate = useNavigate(); + + const handleNewChat = (event: MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return; + event.preventDefault(); + navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } }); + }; + + if (collapsed) { + return ( + + ); + } + + return ( + + + + + CM 助手 + + + + + + + + + {NAV_ITEMS.map((item) => { + const active = item.href === NEW_CHAT_PATH + ? pathname === NEW_CHAT_PATH + : item.href === "/page/workspace/chats" + ? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH + : pathname.startsWith(item.match); + const Icon = item.icon; + return ( + + {item.href === "/page/workspace/agents" ? ( + + ) : ( + + )} + {item.label} + + ); + })} + + + + + + + + + + + + ); +} diff --git a/frontend-web/src/components/workspace/compact-welcome.tsx b/frontend-web/src/components/workspace/compact-welcome.tsx index 9bd26eb3..1607c6f6 100644 --- a/frontend-web/src/components/workspace/compact-welcome.tsx +++ b/frontend-web/src/components/workspace/compact-welcome.tsx @@ -11,38 +11,15 @@ export function CompactWelcome({ className }: { className?: string }) { return ( - {/* CM logo:浅底圆环 + 主色渐变圆角方块 */} - - - - CM - - - - - - 欢迎使用CM助手 + + 你好,欢迎使用 CM 助手 - - 基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 + + 我可以帮你写作、问答、分析和规划各类内容,让工作更高效 ); diff --git a/frontend-web/src/components/workspace/custom-prompt-menu.tsx b/frontend-web/src/components/workspace/custom-prompt-menu.tsx index d6cd72b7..8d168b48 100644 --- a/frontend-web/src/components/workspace/custom-prompt-menu.tsx +++ b/frontend-web/src/components/workspace/custom-prompt-menu.tsx @@ -46,10 +46,12 @@ export function CustomPromptBar({ onInsert, className, currentAgentId, + compact = false, }: { onInsert: (content: string) => void; className?: string; currentAgentId?: string | null; + compact?: boolean; }) { const allPrompts = useCustomPrompts(); const prompts = allPrompts.filter( @@ -81,7 +83,13 @@ export function CustomPromptBar({ return ( <> - + {prompts.length > 0 && ( <> {overflowing && ( @@ -111,6 +119,7 @@ export function CustomPromptBar({ title={prompt.content} className={cn( "border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors", + compact && "border-slate-200 bg-white px-3.5 py-1.5 text-[12px] text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50/60 hover:text-blue-700", "dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700", )} > @@ -139,7 +148,11 @@ export function CustomPromptBar({ type="button" variant="ghost" size="icon" - className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full" + className={cn( + "text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full", + compact && + "h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 dark:border-slate-700 dark:bg-slate-900", + )} title="管理常用提示词" aria-label="管理常用提示词" onClick={() => setManageOpen(true)} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 2bed313f..e63eea63 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -1,16 +1,20 @@ "use client"; import type { ChatStatus } from "ai"; -import { - BrainCircuitIcon, - BookMarkedIcon, - CheckIcon, - FileTextIcon, - GraduationCapIcon, - LightbulbIcon, - PaperclipIcon, - PlusIcon, - ShrinkIcon, +import { + BarChart3Icon, + BrainCircuitIcon, + BookMarkedIcon, + CheckIcon, + ClipboardCheckIcon, + FileTextIcon, + GraduationCapIcon, + LightbulbIcon, + MessageCircleQuestionIcon, + PaperclipIcon, + PlusIcon, + RefreshCwIcon, + ShrinkIcon, SparklesIcon, RocketIcon, XIcon, @@ -102,7 +106,58 @@ import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; -type InputMode = "flash" | "thinking" | "pro" | "ultra"; +type InputMode = "flash" | "thinking" | "pro" | "ultra"; + +const COMPACT_QUICK_STARTS = [ + { + id: "report", + title: "撰写报告", + content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。", + icon: FileTextIcon, + }, + { + id: "knowledge", + title: "知识问答", + content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。", + icon: MessageCircleQuestionIcon, + }, + { + id: "planning", + title: "任务规划", + content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。", + icon: ClipboardCheckIcon, + }, + { + id: "analysis", + title: "数据分析", + content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。", + icon: BarChart3Icon, + }, + { + id: "solution", + title: "方案策划", + content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。", + icon: LightbulbIcon, + }, + { + id: "summary", + title: "内容总结", + content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。", + icon: BookMarkedIcon, + }, + { + id: "research", + title: "调研提纲", + content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。", + icon: GraduationCapIcon, + }, + { + id: "polish", + title: "文案润色", + content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。", + icon: SparklesIcon, + }, +] as const; function getResolvedMode( @@ -210,17 +265,41 @@ export function InputBox({ const lastGeneratedForAiIdRef = useRef(null); const wasStreamingRef = useRef(false); - const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [compactPromptOrder, setCompactPromptOrder] = useState(0); const [pendingSuggestion, setPendingSuggestion] = useState( null, ); const { questions: recommendedQuestions } = useRecommendedQuestions(); const userPreferences = useUserPreferences(); - const showAdminRecommendedQuestions = + const showAdminRecommendedQuestions = !hideAdminRecommendedQuestions && !userPreferences.hide_admin_prompt_prefix && - recommendedQuestions.length > 0; + recommendedQuestions.length > 0; + const compactQuickStarts = useMemo(() => { + if (!hideAdminRecommendedQuestions && recommendedQuestions.length > 0) { + return recommendedQuestions.map((configured, index) => { + const fallback = + COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!; + return { + ...fallback, + id: configured.id, + title: desensitize(configured.title) || `提示词 ${index + 1}`, + content: configured.content || fallback.content, + }; + }); + } + return [...COMPACT_QUICK_STARTS]; + }, [hideAdminRecommendedQuestions, recommendedQuestions]); + const orderedCompactQuickStarts = useMemo(() => { + if (compactQuickStarts.length < 2) return compactQuickStarts; + const offset = compactPromptOrder % compactQuickStarts.length; + return [ + ...compactQuickStarts.slice(offset), + ...compactQuickStarts.slice(0, offset), + ]; + }, [compactPromptOrder, compactQuickStarts]); useEffect(() => { if (models.length === 0) { @@ -252,10 +331,11 @@ export function InputBox({ const resolvedModelName = selectedModel?.name; - const supportThinking = useMemo( - () => selectedModel?.supports_thinking ?? false, - [selectedModel], - ); + const supportThinking = useMemo( + () => selectedModel?.supports_thinking ?? false, + [selectedModel], + ); + const effectiveMode = getResolvedMode(context.mode, supportThinking); const supportReasoningEffort = useMemo( () => selectedModel?.supports_reasoning_effort ?? false, @@ -529,14 +609,68 @@ export function InputBox({ }, [context.model_name, disabled, isMock, status, thread.messages, threadId]); return ( - + {extraHeader && ( {extraHeader} )} - {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( + {isNewThread && searchParams.get("mode") !== "skill" && compact && ( + + + + 你可以这样问我 + + setCompactPromptOrder((current) => current + 1)} + className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-blue-600 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-blue-500/15 dark:hover:bg-slate-800 dark:hover:text-blue-300" + > + + + + + {orderedCompactQuickStarts.map((item) => { + return ( + { + textInput.setInput(item.content); + requestAnimationFrame(() => + document + .querySelector( + "textarea[name='message']", + ) + ?.focus(), + ); + }} + > + {item.title} + + ); + })} + + + )} + + {isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && ( {recommendedQuestions.map((q) => ( )} - {isNewThread && ( - { const current = textInput.value?.trim() ?? ""; @@ -612,8 +747,8 @@ export function InputBox({ ? "*:data-[slot='input-group']:rounded-2xl *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:bg-transparent *:data-[slot='input-group']:shadow-none" : "*:data-[slot='input-group']:rounded-3xl", // 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边) - compact && isNewThread - ? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800" + compact && isNewThread + ? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(37,99,235,0.6),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-blue-300 focus-within:ring-4 focus-within:ring-blue-500/10 dark:border-slate-700 dark:bg-slate-800" : // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边 // 对话态:常规白卡 + 中性边框 + 轻阴影 isNewThread @@ -637,7 +772,8 @@ export function InputBox({ className={cn( "size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60", isNewThread && "min-h-[110px]!", - compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]", + compact && + "min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]", )} disabled={disabled} placeholder={t.inputBox.placeholder} @@ -648,7 +784,7 @@ export function InputBox({ - {context.mode === "flash" && } - {context.mode === "thinking" && ( - - )} - {context.mode === "pro" && ( - - )} - {context.mode === "ultra" && ( - - )} + {effectiveMode === "flash" && } + {effectiveMode === "thinking" && ( + + )} + {effectiveMode === "pro" && ( + + )} + {effectiveMode === "ultra" && ( + + )} - {(context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && - t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} + {(effectiveMode === "flash" && t.inputBox.flashMode) || + (effectiveMode === "thinking" && + t.inputBox.reasoningMode) || + (effectiveMode === "pro" && t.inputBox.proMode) || + (effectiveMode === "ultra" && t.inputBox.ultraMode)} @@ -1124,8 +1255,10 @@ export function InputBox({ onOpenChange={setModelDialogOpen} > - - {selectedModel?.display_name} + {selectedModel?.display_name || "默认模型"} @@ -1171,8 +1304,8 @@ export function InputBox({ ; + } + + return ( <> - + const badgeGroup = ( + + {skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)} {skill.name_zh?.trim() && ( @@ -902,12 +896,12 @@ export function SkillRow({ > ); - const actionButtons = ( + const actionButtons = ( <> - setTagPickerOpen(true)} title={t.adminTags.manageTags} > @@ -917,8 +911,8 @@ export function SkillRow({ )} {canManage && ( setConfirmDeleteOpen(true)} title={t.settings.skills.deleteAction} > @@ -1018,41 +1012,124 @@ export function SkillRow({ )} > - ); + ); + + const iconStyle = skill.name.includes("web") || skill.name.includes("search") + ? { + Icon: GlobeIcon, + className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]", + } + : skill.name.includes("doc") || skill.name.includes("file") + ? { + Icon: FileArchive, + className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]", + } + : skill.source === "agent" + ? { + Icon: WorkflowIcon, + className: "bg-[#2166e8] text-white shadow-[0_6px_16px_rgba(37,99,235,0.22)]", + } + : { + Icon: Upload, + className: "bg-amber-500 text-white shadow-[0_6px_16px_rgba(245,158,11,0.22)]", + }; + const SkillIcon = iconStyle.Icon; + const activityAt = formatDateTime(skill.last_activity_at ?? skill.created_at); + const actionArea = ( + event.stopPropagation()}> + {actionButtons} + + ); return ( <> - {isCard ? ( - - setDetailOpen(true)} - > - {badgeGroup} - - {desensitize(skill.description ?? "")} - - {metaNodes} - - - {actionButtons} - - - ) : ( - - setDetailOpen(true)} - > - {badgeGroup} - - {desensitize(skill.description ?? "")} - - {metaNodes} - - {actionButtons} - - )} + {isCard ? ( + + setDetailOpen(true)} + > + + + + + + {badgeGroup} + + {desensitize(skill.description ?? "")} + + + + + + + {skill.enabled ? "已启用" : "未启用"} + + {skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"} + {activityAt && 更新于 {activityAt}} + + {(skill.tags?.length ?? 0) > 0 && } + + + {actionArea} + + + ) : ( + + + setDetailOpen(true)}> + + + + + {badgeGroup} + + {desensitize(skill.description ?? "")} + + {!isBuiltin && skill.owner_username && ( + { + event.stopPropagation(); + if (!skill.owner_user_id || !onPublisherClick) return; + onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) }); + }} + className={cn( + "mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400", + skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-blue-600 hover:underline", + )} + > + + 发布者:{desensitize(skill.owner_username)} + + )} + + + + {(skill.tags?.length ?? 0) > 0 ? ( + + ) : ( + 未设置标签 + )} + + + 使用次数 + {skill.use_count ? `${skill.use_count} 次` : "暂无调用"} + + + + + {skill.enabled ? "已启用" : "未启用"} + + {activityAt && 更新于 {activityAt}} + + + {actionArea} + + + + )} diff --git a/frontend-web/src/components/workspace/skill-display-sources.tsx b/frontend-web/src/components/workspace/skill-display-sources.tsx index 3993f022..731d171a 100644 --- a/frontend-web/src/components/workspace/skill-display-sources.tsx +++ b/frontend-web/src/components/workspace/skill-display-sources.tsx @@ -53,6 +53,7 @@ export function SourceCard({ referenceTarget?: boolean; }) { const [open, setOpen] = useState(false); + const [showOriginal, setShowOriginal] = useState(false); const [context, setContext] = useState(null); const [contextLoading, setContextLoading] = useState(false); const [contextError, setContextError] = useState(null); @@ -81,7 +82,7 @@ export function SourceCard({ const previewTarget = parseWeKnoraPreviewUrl(source.url); useEffect(() => { - if (!open || !previewTarget) { + if (!open || !showOriginal || !previewTarget) { return; } let cancelled = false; @@ -115,7 +116,7 @@ export function SourceCard({ return () => { cancelled = true; }; - }, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]); + }, [open, showOriginal, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]); return ( <> @@ -128,7 +129,10 @@ export function SourceCard({ highlighted && "border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]", )} - onClick={() => setOpen(true)} + onClick={() => { + setShowOriginal(false); + setOpen(true); + }} > {showReferenceNumber && ( @@ -168,7 +172,15 @@ export function SourceCard({ )} - + { + setOpen(nextOpen); + if (!nextOpen) { + setShowOriginal(false); + } + }} + > @@ -205,7 +217,27 @@ export function SourceCard({ )} - {previewTarget ? ( + {previewTarget && ( + + + + {showOriginal + ? "正在查看命中文段及其前后上下文" + : "当前为检索命中的片段,可继续查看原文上下文"} + + setShowOriginal((current) => !current)} + > + + {showOriginal ? "返回检索片段" : "查看原文"} + + + + )} + {previewTarget && showOriginal ? ( ; }; +export function isWeKnoraSourcePreviewUrl(url?: string): boolean { + return parseWeKnoraPreviewUrl(url) !== null; +} + function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null { - if (!url?.startsWith("/weknora-source-preview?")) { + const normalizedUrl = url + ?.trim() + .replace(/^[\u200B-\u200D\uFEFF]+/, ""); + const marker = "/weknora-source-preview?"; + const start = normalizedUrl?.indexOf(marker) ?? -1; + if (start < 0) { return null; } - const queryIndex = url.indexOf("?"); - const params = new URLSearchParams(url.slice(queryIndex + 1)); + // Some MCP tool results wrap the relative pointer in formatting characters + // or append a newline. Keep accepting that safe internal path, but never + // treat a normal external URL as an original-document preview. + const previewUrl = normalizedUrl! + .slice(start) + .split(/[\r\n\s]/, 1)[0]; + const queryIndex = previewUrl.indexOf("?"); + const params = new URLSearchParams(previewUrl.slice(queryIndex + 1)); const knowledgeBaseId = params.get("knowledge_base_id")?.trim(); const chunkId = params.get("chunk_id")?.trim(); return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null; diff --git a/frontend-web/src/components/workspace/user-identity-badge.tsx b/frontend-web/src/components/workspace/user-identity-badge.tsx index 9e007a37..a3101b56 100644 --- a/frontend-web/src/components/workspace/user-identity-badge.tsx +++ b/frontend-web/src/components/workspace/user-identity-badge.tsx @@ -1,6 +1,6 @@ "use client"; -import { User } from "lucide-react"; +import { UserRound } from "lucide-react"; import { useLiveAccount } from "@/core/auth/use-live-account"; import { useMyPosition } from "@/core/positions"; @@ -15,7 +15,13 @@ import { cn } from "@/lib/utils"; * 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏, * 而非 Header 的白字贴 banner 方案。 */ -export function UserIdentityBadge({ className }: { className?: string }) { +export function UserIdentityBadge({ + className, + variant = "plain", +}: { + className?: string; + variant?: "plain" | "topbar"; +}) { // userInfo belongs to external-app route parameters and can legitimately // outlive a login switch. The visible account name must instead match the // settings page's current auth identity. @@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) { return ( - - + + + {variant === "topbar" && ( + + )} - + {displayName} diff --git a/frontend-web/src/components/workspace/workspace-container.tsx b/frontend-web/src/components/workspace/workspace-container.tsx index 329449b4..6ca12e0c 100644 --- a/frontend-web/src/components/workspace/workspace-container.tsx +++ b/frontend-web/src/components/workspace/workspace-container.tsx @@ -12,8 +12,10 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { useI18n } from "@/core/i18n/hooks"; -import { cn } from "@/lib/utils"; +import { useI18n } from "@/core/i18n/hooks"; +import { cn } from "@/lib/utils"; +import { ThemeSwitcher } from "@/components/workspace/appearance-controls"; +import { UserIdentityBadge } from "@/components/workspace/user-identity-badge"; export function WorkspaceContainer({ className, @@ -93,9 +95,15 @@ export function WorkspaceHeader({ - {rightActions ? ( - {rightActions} - ) : null} + + {rightActions} + + + + ); } @@ -123,7 +131,8 @@ function nameOfSegment( t: ReturnType["t"], ) { if (!segment) return t.common.home; - if (segment === "workspace") return t.breadcrumb.workspace; - if (segment === "chats") return t.breadcrumb.chats; - return segment[0]?.toUpperCase() + segment.slice(1); -} + if (segment === "workspace") return t.breadcrumb.workspace; + if (segment === "chats") return t.breadcrumb.chats; + if (segment === "agents") return t.agents.title; + return segment[0]?.toUpperCase() + segment.slice(1); +} diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index 39206d90..b8db3b9d 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -1,118 +1,44 @@ -"use client"; - -import { - Sidebar, - SidebarHeader, - SidebarContent, - SidebarFooter, - SidebarRail, - useSidebar, -} from "@/components/ui/sidebar"; -import { useTheme } from "@/contexts/ThemeContext"; -import { cn } from "@/lib/utils"; -import { usePathname } from "next/navigation"; - -import { GeneralQaSidebar } from "./general-qa-sidebar"; -import { RecentChatList } from "./recent-chat-list"; -import { RecentRoundtableList } from "./recent-roundtable-list"; -import { RecentWritingList } from "./recent-writing-list"; -import { WorkspaceHeader } from "./workspace-header"; -import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; -import { WorkspaceNavMenu } from "./workspace-nav-menu"; - -export function WorkspaceSidebar({ - compactChrome = false, - className, - ...props -}: React.ComponentProps & { compactChrome?: boolean }) { - const { open: isSidebarOpen } = useSidebar(); - const { theme } = useTheme(); - const pathname = usePathname(); - const useWhiteSidebar = compactChrome && theme === "light"; - // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 - // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 - // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 - const isGeneralQa = pathname.startsWith("/page/workspace/chats"); - - // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 - // collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对 - // sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从 - // --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。 - if (compactChrome) { - return ( - - - - {isGeneralQa ? ( - // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成 - // GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。 - - ) : ( - <> - - - > - )} - - - - - ); - } - - return ( - <> - - {!compactChrome && ( - - - - )} - - - {isSidebarOpen && } - {/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐: - 仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */} - {isSidebarOpen && !compactChrome && } - {isSidebarOpen && !compactChrome && } - - {!compactChrome && ( - - - - )} - - - > - ); -} +"use client"; + +import { + Sidebar, + SidebarContent, + useSidebar, +} from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +import { CmChatSidebar } from "./cm-chat-sidebar"; + +export function WorkspaceSidebar({ + className, + compactChrome: _compactChrome, + ...props +}: React.ComponentProps & { compactChrome?: boolean }) { + const { open: isSidebarOpen, toggleSidebar } = useSidebar(); + + return ( + + + + + + + + ); +} diff --git a/frontend-web/src/pages/AgentsPage.tsx b/frontend-web/src/pages/AgentsPage.tsx index 412dfc02..bfc436ac 100644 --- a/frontend-web/src/pages/AgentsPage.tsx +++ b/frontend-web/src/pages/AgentsPage.tsx @@ -1,5 +1,15 @@ -import { AgentGallery } from "@/components/workspace/agents/agent-gallery"; - -export default function AgentsPage() { - return ; -} +import { AgentGallery } from "@/components/workspace/agents/agent-gallery"; +import { + WorkspaceBody, + WorkspaceContainer, +} from "@/components/workspace/workspace-container"; + +export default function AgentsPage() { + return ( + + + + + + ); +} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 8ee81291..b6271b65 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -35,7 +35,6 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato import { UserIdentityBadge } from "@/components/workspace/user-identity-badge"; import { Welcome } from "@/components/workspace/welcome"; import { CompactWelcome } from "@/components/workspace/compact-welcome"; -import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled"; import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route"; import { useSidebar } from "@/components/ui/sidebar"; import { Button } from "@/components/ui/button"; @@ -51,7 +50,6 @@ import { useThreadSettings, } from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; -import { useLayoutMode } from "@/core/system-settings/hooks"; import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; import { OptType, writeYLog } from "@/core/log/y-log"; @@ -103,8 +101,6 @@ export default function ChatPage() { const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } = useSidebar(); const { t } = useI18n(); - // 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled) - const [effectiveLayoutMode] = useLayoutMode(); const [showFollowups, setShowFollowups] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -444,20 +440,8 @@ export default function ChatPage() { MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM : undefined; const isLandingState = isNewThread && !hasStartedConversation; - // 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画; - // 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 —— - // 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式 - // 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝 - // 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。 - const layoutMode = effectiveLayoutMode; - const chromeByPreference = - layoutMode === "full" - ? true - : layoutMode === "compact" - ? false - : readSidebarEnabled(); - const compactChrome = - chromeByPreference && !isChatsIframeWorkspacePath(pathname); + // 正常工作区统一使用新版侧栏和落地页;iframe 专用路由继续沿用嵌入布局。 + const compactChrome = !isChatsIframeWorkspacePath(pathname); const compactLanding = isLandingState && compactChrome; return ( @@ -470,16 +454,12 @@ export default function ChatPage() { compactChrome && "workspace-chat-bg", )} > - + {(!isLandingState || compactChrome) && ( + - + {!isLandingState && ( + + )} {/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */} @@ -507,7 +487,16 @@ export default function ChatPage() { 与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */} {/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角, 与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */} - {!compactChrome && ( + {compactChrome ? ( + <> + + + + > + ) : ( <> @@ -521,6 +510,7 @@ export default function ChatPage() { )} + )} {/* @@ -556,7 +546,7 @@ export default function ChatPage() { "pointer-events-none absolute z-30 flex justify-center px-4", // 紧凑落地态:整块垂直+水平居中于对话区 compactLanding - ? "inset-0 items-center" + ? "inset-0 items-start overflow-y-auto pt-[clamp(82px,16vh,146px)] pb-12" : "right-0 bottom-0 left-0", )} > @@ -568,7 +558,7 @@ export default function ChatPage() { !compactLanding && "-translate-y-[calc(50vh-96px)]", compactLanding - ? "max-w-(--container-width-xl)" + ? "max-w-[1024px]" : isLandingState ? "max-w-(--container-width-md)" : "max-w-(--container-width-lg)", @@ -587,7 +577,7 @@ export default function ChatPage() { {isLandingState && mounted && (compactLanding ? ( - + ) : ( ))} {mounted ? ( - + - ) : null diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index 208f82bc..5bfd2c96 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -61,8 +61,10 @@ function PageLayout() { // (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome** // (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。 const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent"); + const isWorkspacePage = location.pathname.startsWith("/page/workspace"); const pageSidebarAllowed = (forceChromeForDashboardAgent || chromeByPreference) && + !isWorkspacePage && !isChatsIframeWorkspacePath(location.pathname) && !embedActive && // `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。 @@ -114,7 +116,7 @@ function PageLayout() { pageSidebarCollapsed={collapsed} pageSidebarHidden={!pageSidebarAllowed} headerHidden={!pageSidebarAllowed} - compactWorkspaceSidebar={pageSidebarAllowed} + compactWorkspaceSidebar={pageSidebarAllowed || isWorkspacePage} pageSidebarDark={isDark} /> } diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 96a989dd..be415952 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -11,7 +11,6 @@ import { ThemeSwitcher, } from '@/components/workspace/appearance-controls'; import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar'; -import { UserIdentityBadge } from '@/components/workspace/user-identity-badge'; import { CommandPalette } from '@/components/workspace/command-palette'; import { Toaster } from 'sonner'; import { SubtasksProvider } from '@/core/tasks/context'; @@ -79,28 +78,6 @@ function isInIframe(): boolean { } } -function shouldShowCompactTopActions(pathname: string): boolean { - return ( - pathname === '/page/workspace/agents' || - pathname === '/page/workspace/skills' || - pathname === '/page/workspace/memory' || - pathname === '/page/workspace/knowledge' || - pathname.startsWith('/page/workspace/knowledge/') - ); -} - -function CompactWorkspaceTopActions() { - return ( - - - - - - - - ); -} - function CompactAppearanceActions() { return ( <> @@ -137,11 +114,6 @@ export function WorkspaceLayout({ }) { // 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页 // (如智能体对话,为聊天区更宽而默认收起)维持原默认。 - const pathname = useLocation().pathname; - const isGeneralQa = pathname.startsWith('/page/workspace/chats'); - const showCompactTopActions = - headerHidden && shouldShowCompactTopActions(pathname); - const pageSidebarWidth = pageSidebarHidden ? '0rem' : pageSidebarDark @@ -164,12 +136,13 @@ export function WorkspaceLayout({ { '--page-sidebar-width': pageSidebarWidth, '--header-height': headerHidden ? '0rem' : '4rem', + '--sidebar-width': '18.5rem', } as CSSProperties } key={String(compactWorkspaceSidebar)} data-page-sidebar-collapsed={pageSidebarCollapsed} defaultOpen={ - defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar) + defaultHistorySidebarOpen ?? true } > {!workspaceSidebarHidden && ( @@ -178,14 +151,7 @@ export function WorkspaceLayout({ - {showCompactTopActions ? ( - <> - - {children} - > - ) : ( - children - )} + {children} diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css index 097e616c..ce84a433 100644 --- a/frontend-web/src/styles/globals.css +++ b/frontend-web/src/styles/globals.css @@ -1290,9 +1290,9 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection { * agent surfaces on classic rgba()/hex colours instead. * -------------------------------------------------------------------------- */ .agent-avatar { - background-color: #feeded; - color: #cf1322; - box-shadow: inset 0 0 0 1px rgba(190, 35, 35, 0.15); + background-color: #edf4ff; + color: #1f5fdb; + box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16); } .dark .agent-avatar { diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index f3d11696..6ffbe232 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -846,7 +846,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me ### WeKnora Retrieval Skill -`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. The frontend source card recognizes it and opens a context dialog; its call to authenticated Gateway route `/api/weknora/source-context` is proxied to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context`, returning only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`. +`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. Both the configured Skill source-card path and the generic MCP-result fallback recognize this URL; the source-information dialog never renders it as text and instead exposes a “查看原文” button. The button makes the authenticated Gateway call to `/api/weknora/source-context`, which then proxies to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context` and returns only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`. ### Embedded Client (`packages/harness/deerflow/client.py`) diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 7e64f109..c00d3fa9 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -78,7 +78,7 @@ Per-thread isolated execution with virtual path translation: - **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories - **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory - **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths -- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results now contain a credential-free `url` pointer; the source card's “查看原文” dialog calls DeerFlow's authenticated `/api/weknora/source-context` proxy, which retrieves the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt. +- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results contain a credential-free `url` pointer, which is never rendered as text: both configured Skill source cards and the generic MCP-result fallback recognize it and provide a “查看原文” button. Selecting it calls DeerFlow's authenticated `/api/weknora/source-context` proxy and displays the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt. - **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match - **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
{t.agents.emptyTitle}
@@ -276,8 +281,7 @@ export function AgentGallery() {
- {t.agents.description} +
+ 创建并管理专属的 AI 智能体,助力更高效的工作与决策。
- 基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 +
+ 我可以帮你写作、问答、分析和规划各类内容,让工作更高效
- {desensitize(skill.description ?? "")} -
+ {desensitize(skill.description ?? "")} +
更新于 {activityAt}
{displayName}