Compare commits

...
3 Commits
53 changed files with 1518 additions and 485 deletions

No files matched your search

+2
View File
@@ -50,4 +50,6 @@
# 可选 # 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY= # VITE_STATIC_WEBSITE_ONLY=
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
# VITE_FORCE_COLLAPSED_SIDEBAR=false
# VITE_GITHUB_OAUTH_TOKEN= # VITE_GITHUB_OAUTH_TOKEN=
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
<title id="title">CM 助手</title>
<defs>
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#5DB5FF"/>
<stop offset="1" stop-color="#328AF2"/>
</linearGradient>
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
<stop stop-color="#62B2FF"/>
<stop offset="1" stop-color="#4B98F4"/>
</linearGradient>
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
<stop stop-color="#2C7CF0"/>
<stop offset="1" stop-color="#1F5EDA"/>
</linearGradient>
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#276DE3"/>
<stop offset="1" stop-color="#4E9DF5"/>
</linearGradient>
</defs>
<!-- The mark is drawn as four independent folded faces. Together they form
the open C-shaped cube used in the product reference. -->
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -91,6 +91,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// 静态站点模式开关;一般部署业务系统时保持 false。 // 静态站点模式开关;一般部署业务系统时保持 false。
VITE_STATIC_WEBSITE_ONLY: "false", VITE_STATIC_WEBSITE_ONLY: "false",
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。 // GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "", VITE_GITHUB_OAUTH_TOKEN: "",
}; };
+1 -1
View File
@@ -86,7 +86,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
attribute="class" attribute="class"
storageKey="strategy-theme" storageKey="strategy-theme"
defaultTheme="light" defaultTheme="light"
themes={["light", "dark", "lightblue"]} themes={["light", "dark", "lightblue", "lightgreen"]}
enableSystem={false} enableSystem={false}
disableTransitionOnChange disableTransitionOnChange
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
+3 -1
View File
@@ -27,7 +27,9 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem"; // Keep the workbench navigation compact across every theme. The visual theme
// only changes tokens; the information density should remain consistent.
const SIDEBAR_WIDTH = "14rem";
const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b"; const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -0,0 +1,53 @@
"use client";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
type AgentBrandIconProps = {
className?: string;
size?: "nav" | "sm" | "md" | "lg";
};
const SIZE_STYLES = {
nav: {
frame: "size-[22px] rounded-md",
},
sm: {
frame: "size-10 rounded-full",
},
md: {
frame: "size-12 rounded-full",
},
lg: {
frame: "size-16 rounded-full",
},
} as const;
export function AgentBrandIcon({
className,
size = "md",
}: AgentBrandIconProps) {
const styles = SIZE_STYLES[size];
const { theme } = useTheme();
const avatarSrc =
theme === "light" ? "/agent-avatar-red.png" : "/agent-avatar.png";
return (
<span
aria-hidden="true"
data-agent-brand-icon={size}
className={cn(
"relative inline-flex shrink-0 overflow-hidden rounded-full",
styles.frame,
className,
)}
>
<img
src={avatarSrc}
alt=""
className="size-full object-contain"
draggable={false}
/>
</span>
);
}
@@ -1,13 +1,13 @@
"use client"; "use client";
import { import {
BotIcon,
ClockIcon, ClockIcon,
CopyIcon, CopyIcon,
EyeOffIcon, EyeOffIcon,
GlobeIcon, GlobeIcon,
LockIcon, LockIcon,
MessageSquareIcon, MessageSquareIcon,
MoreHorizontalIcon,
PinIcon, PinIcon,
ShieldIcon, ShieldIcon,
SparklesIcon, SparklesIcon,
@@ -46,6 +46,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, 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 { Input } from "@/components/ui/input";
import { import {
Select, Select,
@@ -60,6 +67,7 @@ import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog"; import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-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 { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -551,7 +559,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{isOwned && ( {isOwned && (
<Badge <Badge
variant="default" variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs" className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
> >
我的 我的
</Badge> </Badge>
@@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{isFromOthers && ( {isFromOthers && (
<Badge <Badge
variant="outline" variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs" className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"} title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
> >
来自他人 来自他人
@@ -568,7 +576,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && ( {(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge <Badge
variant="outline" variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs" className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title="该智能体已对所有用户可见" title="该智能体已对所有用户可见"
> >
<GlobeIcon className="size-3" /> <GlobeIcon className="size-3" />
@@ -664,7 +672,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
...(agent.tool_groups ?? []).map((group) => ({ ...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`, key: `tg:${group}`,
node: ( node: (
<Badge key={`tg:${group}`} variant="outline" className="text-xs"> <Badge
key={`tg:${group}`}
variant="outline"
className="border-0 bg-primary/5 text-primary hover:bg-primary/10 px-2.5 py-1 text-xs font-normal"
>
{desensitize(group)} {desensitize(group)}
</Badge> </Badge>
), ),
@@ -675,7 +687,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
<Badge <Badge
key={`sk:${skill}`} key={`sk:${skill}`}
variant="secondary" variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs" className="bg-primary/5 text-primary hover:bg-primary/10 cursor-pointer border-0 px-2.5 py-1 text-xs font-normal"
title={t.settings.skills.viewDetailAction} title={t.settings.skills.viewDetailAction}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -825,78 +837,179 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const chatButton = ( const chatButton = (
<Button <Button
variant={isList ? "default" : "outline"}
size="sm" size="sm"
className={cn(isList ? "shrink-0" : "w-full")} className={cn(
isList
? "bg-primary hover:bg-primary/90 shrink-0 rounded-xl px-4 font-medium text-primary-foreground"
: "border-primary/35 text-primary hover:border-primary/55 hover:bg-primary/5 h-11 w-full rounded-lg bg-white font-medium shadow-none dark:bg-slate-900 dark:text-primary",
)}
onClick={handleChat} onClick={handleChat}
> >
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" /> <MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat} 开始对话
</Button> </Button>
); );
const cardStatusBadge = agent.is_private ? (
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
私密
</Badge>
) : agent.published || isBuiltin ? (
<Badge className="border-0 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-normal dark:bg-primary/15 dark:text-primary">
<span className="bg-primary mr-1.5 size-1.5 rounded-full" />
已发布
</Badge>
) : (
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
运行中
</Badge>
);
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
const cardActionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
title="更多操作"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{canFavorite && (
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
{isFavorite ? "取消收藏" : "收藏到我的"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
{isPinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
<SparklesIcon className="size-4" />
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
<TagsIcon className="size-4" />
{t.adminTags.manageTags}
</DropdownMenuItem>
{isOwned && (
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
{agent.published ? "取消发布" : "发布"}
</DropdownMenuItem>
)}
{(isOwned || isAdmin) && (
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
<ShieldIcon className="size-4" />
{agent.is_private ? "移出私密广场" : "放入私密广场"}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
<CopyIcon className="size-4" />
复制智能体
</DropdownMenuItem>
{canEdit && (
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<SquarePenIcon className="size-4" />
编辑智能体
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" />
{t.agents.delete}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
const cardLayout = ( const cardLayout = (
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"> <Card
<CardHeader className="min-w-0 pb-2"> className={cn(
<div className="flex min-w-0 items-start justify-between gap-2"> "group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900",
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"> agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20",
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"> )}
<BotIcon className="h-5 w-5" /> >
</div> <CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
<div className="min-w-0 flex-1 overflow-hidden"> {agent.featured_order != null ? (
<CardTitle <>
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap" <div className="mb-1 flex h-7 items-center justify-between">
title={desensitize(agent.name)} <Badge className="border border-primary/20 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-medium dark:border-primary/40 dark:bg-primary/15 dark:text-primary">
> <SparklesIcon className="mr-1 size-3.5 fill-current" />
{desensitize(agent.name)} 推荐
</CardTitle> </Badge>
{agent.model && ( {cardActionsMenu}
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div> </div>
<AgentBrandIcon size="lg" />
</>
) : (
<div className="flex items-start justify-between">
<AgentBrandIcon size="lg" />
{cardActionsMenu}
</div> </div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1"> )}
{statusBadges} <CardTitle
</div> className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
</div> title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.description && ( {agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm"> <CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
{desensitize(agent.description)} {desensitize(agent.description)}
</CardDescription> </CardDescription>
)} )}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
</CardHeader> </CardHeader>
{(agent.tags?.length ?? 0) > 0 && ( {(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2"> <CardContent className="px-5 pt-0 pb-1.5">
<TagChipList tags={agent.tags!} /> <TagChipList tags={agent.tags!} />
</CardContent> </CardContent>
)} )}
{hasSkillsOrTools && ( {hasSkillsOrTools && (
<CardContent className="pt-0 pb-2"> <CardContent className="px-5 pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} /> <ClampedBadgeRow items={skillBadgeItems} />
</CardContent> </CardContent>
)} )}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2"> <CardFooter className="mt-auto flex flex-col items-stretch gap-4 px-5 pt-2 pb-4">
{showActionRow && ( <div className="flex min-w-0 items-center gap-3">
<div className="flex flex-wrap items-center justify-end gap-1"> {cardStatusBadge}
{actionButtons} <span
</div> className="min-w-0 truncate text-xs text-slate-400"
)} title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
>
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
</span>
</div>
{chatButton} {chatButton}
</CardFooter> </CardFooter>
</Card> </Card>
); );
const listLayout = ( const listLayout = (
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]"> <div className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900">
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"> <AgentBrandIcon className="agent-avatar" size="md" />
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span <span
@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
BotIcon,
CheckIcon, CheckIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
@@ -43,6 +42,7 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
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 { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats"; import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
import { useArtifactsOptional } from "@/components/workspace/artifacts"; import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list"; import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
@@ -520,9 +520,7 @@ export function AgentDetailPanel({
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */} {/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
<section className="bg-card/60 rounded-xl border p-4"> <section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1"> <AgentBrandIcon className="agent-avatar" size="lg" />
<BotIcon className="text-primary size-6" />
</div>
<div className="min-w-0 flex-1 space-y-1.5"> <div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? ( {editing && draft ? (
<Input <Input
@@ -1,6 +1,15 @@
"use client"; "use client";
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react"; import {
ArrowDownUpIcon,
BotIcon,
Loader2,
LockIcon,
PlusIcon,
SearchIcon,
ShieldIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -10,17 +19,17 @@ import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination"; import { Pagination } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks"; import { usePrivateSquareStatus, usePublishSquares } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card"; import { AgentCard } from "./agent-card";
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
type AgentStatusFilter = "all" | "published" | "draft" | "private" | "builtin";
// Within a single tab the verified password is kept in sessionStorage so a // Within a single tab the verified password is kept in sessionStorage so a
// refresh / route navigation does not re-prompt. Cleared on tab close, so the // refresh / route navigation does not re-prompt. Cleared on tab close, so the
@@ -32,11 +41,10 @@ export function AgentGallery() {
const router = useRouter(); const router = useRouter();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<string>("mine"); const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc"); const [sort, setSort] = useState<AgentSort>("time_desc");
const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>("all");
const { data: publishSquares } = usePublishSquares(); const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the // Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default). // 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. // frontend's desensitize map, so typing the displayed abbreviation (e.g.
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the // "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
// filters change, every tab returns to its first page. // filters change, every tab returns to its first page.
const tagKey = tagIds.join(",");
useEffect(() => { useEffect(() => {
setPages({}); setPages({});
}, [search, tagKey, sort, publisher?.id]); }, [search, sort, publisher?.id, statusFilter]);
// If the active square tab disappears (admin removed that square), fall back // If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab. // to "我的" so the gallery never points at a dead tab.
@@ -108,7 +115,6 @@ export function AgentGallery() {
} = useAgentsPaginated({ } = useAgentsPaginated({
scope: mainScope, scope: mainScope,
search, search,
tagIds,
sort, sort,
page: mainPage, page: mainPage,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -126,7 +132,6 @@ export function AgentGallery() {
error: privateError, error: privateError,
} = usePrivateAgentsPaginated(privatePassword, { } = usePrivateAgentsPaginated(privatePassword, {
search, search,
tagIds,
sort, sort,
page: pages.private ?? 1, page: pages.private ?? 1,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -177,21 +182,22 @@ export function AgentGallery() {
const handlePublisherClick = (p: { id: string; label: string }) => const handlePublisherClick = (p: { id: string; label: string }) =>
setPublisher(p); setPublisher(p);
const renderGrid = (list: Agent[]) => const filterByStatus = (agent: Agent) => {
viewMode === "list" ? ( if (statusFilter === "published") return agent.published && !agent.is_private;
<div className="flex flex-col gap-2"> if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin;
{list.map((agent) => ( if (statusFilter === "private") return agent.is_private;
<AgentCard if (statusFilter === "builtin") return agent.builtin;
key={agent.id} return true;
agent={agent} };
layout="list"
onPublisherClick={handlePublisherClick} const renderGrid = (list: Agent[]) => {
/> const visibleAgents = list.filter(filterByStatus);
))} if (visibleAgents.length === 0 && list.length > 0) {
</div> return renderEmpty("当前状态筛选下没有智能体。");
) : ( }
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> return (
{list.map((agent) => ( <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleAgents.map((agent) => (
<AgentCard <AgentCard
key={agent.id} key={agent.id}
agent={agent} agent={agent}
@@ -200,6 +206,7 @@ export function AgentGallery() {
))} ))}
</div> </div>
); );
};
/** Loading / empty / (grid + pagination) for one paginated tab. */ /** Loading / empty / (grid + pagination) for one paginated tab. */
const renderListBody = (opts: { const renderListBody = (opts: {
@@ -264,20 +271,17 @@ export function AgentGallery() {
}; };
const renderEmpty = (description: string, showNewButton = false) => ( const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center"> <div className="flex h-72 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-primary/25 bg-white/70 text-center shadow-[0_12px_36px_rgba(15,23,42,0.05)] dark:border-primary/40 dark:bg-slate-900/70">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]"> <AgentBrandIcon size="lg" />
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
</div>
<div> <div>
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p> <p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]"> <p className="text-muted-foreground mt-1 text-sm">
{description} {description}
</p> </p>
</div> </div>
{showNewButton && ( {showNewButton && (
<Button <Button
variant="outline" className="bg-primary hover:bg-primary/90 mt-2 rounded-xl px-5 text-primary-foreground shadow-[0_8px_20px_rgba(15,23,42,0.16)]"
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
onClick={handleNewAgent} onClick={handleNewAgent}
> >
<PlusIcon className="mr-1.5 h-4 w-4" /> <PlusIcon className="mr-1.5 h-4 w-4" />
@@ -364,17 +368,22 @@ export function AgentGallery() {
}; };
return ( return (
<div className="flex size-full min-h-0 flex-col"> <div className="flex size-full min-h-0 flex-col overflow-hidden bg-white dark:bg-slate-950">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex shrink-0 items-start justify-between px-5 pt-7 pb-6 sm:px-8">
<div> <div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1> <h1 className="text-[26px] leading-none font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
<p className="text-muted-foreground mt-0.5 text-sm"> {t.agents.title}
{t.agents.description} </h1>
<p className="mt-2.5 text-[15px] text-slate-500 dark:text-slate-400">
创建并管理专属的 AI 智能体,助力更高效的工作与决策。
</p> </p>
</div> </div>
<Button onClick={handleNewAgent}> <Button
<PlusIcon className="mr-1.5 h-4 w-4" /> onClick={handleNewAgent}
className="bg-primary hover:bg-primary/90 h-11 w-[148px] rounded-xl px-0 text-[15px] font-medium text-primary-foreground shadow-[0_9px_22px_rgba(15,23,42,0.16)]"
>
<PlusIcon className="mr-2 h-[18px] w-[18px]" />
{t.agents.newAgent} {t.agents.newAgent}
</Button> </Button>
</div> </div>
@@ -384,43 +393,99 @@ export function AgentGallery() {
value={activeTab} value={activeTab}
onValueChange={(v) => { onValueChange={(v) => {
setActiveTab(v); setActiveTab(v);
// Each tab starts from a clean slate: switching tabs clears the // Each tab starts from a clean slate instead of carrying filters over.
// keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below.
setSearch(""); setSearch("");
setTagIds([]);
setSort("time_desc"); setSort("time_desc");
setStatusFilter("all");
setPublisher(null); setPublisher(null);
}} }}
className="flex min-h-0 flex-1 flex-col overflow-hidden" className="flex min-h-0 flex-1 flex-col overflow-hidden"
> >
<div className="mx-6 mt-4 flex flex-col gap-3"> <div className="shrink-0 px-5 sm:px-8">
<div className="flex items-center justify-between gap-3"> <div className="flex min-h-12 flex-col gap-3 border-b border-slate-200/90 lg:flex-row lg:items-end lg:justify-between dark:border-slate-800">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start"> <TabsList variant="line" className="h-auto w-auto max-w-full flex-wrap justify-start self-start rounded-none border-0 bg-transparent p-0">
<TabsTrigger value="mine">我的</TabsTrigger> <TabsTrigger
value="mine"
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-3 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
>
我的
</TabsTrigger>
{squareTabs.map((sq) => ( {squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}> <TabsTrigger
key={sq.id}
value={sq.id}
title={sq.description || sq.name}
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
>
{sq.name} {sq.name}
</TabsTrigger> </TabsTrigger>
))} ))}
{privateFeatureAvailable && ( {privateFeatureAvailable && (
<TabsTrigger value="private"> <TabsTrigger
value="private"
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
>
<LockIcon className="mr-1 h-3.5 w-3.5" /> <LockIcon className="mr-1 h-3.5 w-3.5" />
私密广场 私密广场
</TabsTrigger> </TabsTrigger>
)} )}
{isAdmin && ( {isAdmin && (
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)"> <TabsTrigger
value="builtin"
title="内置/功能型智能体(仅管理员可见)"
className="h-12 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:!shadow-none"
>
<BotIcon className="mr-1 h-3.5 w-3.5" /> <BotIcon className="mr-1 h-3.5 w-3.5" />
内置智能体 内置智能体
</TabsTrigger> </TabsTrigger>
)} )}
</TabsList> </TabsList>
<div className="flex items-center gap-3"> {showSearchBar && (
<span className="text-muted-foreground text-sm whitespace-nowrap"> <div className="flex flex-wrap items-center justify-end gap-3 pb-2">
共 {activeTotal} 个 <div className="relative w-[320px] max-w-full">
</span> <SearchIcon className="pointer-events-none absolute top-1/2 left-3.5 size-[18px] -translate-y-1/2 text-slate-500" />
<ViewModeToggle value={viewMode} onChange={setViewMode} /> <Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索智能体名称或描述"
className="h-11 rounded-xl border-slate-200 bg-white pl-11 text-sm shadow-none placeholder:text-slate-400 focus-visible:border-primary/50 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900"
/>
{(activeTab === "private" ? isPrivateFetching : mainFetching) && (
<Loader2 className="text-primary absolute top-1/2 right-3.5 size-4 -translate-y-1/2 animate-spin" />
)}
</div>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as AgentStatusFilter)}
>
<SelectTrigger className="h-11 w-[150px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="published">已发布</SelectItem>
<SelectItem value="draft">未发布</SelectItem>
<SelectItem value="private">私密</SelectItem>
<SelectItem value="builtin">内置</SelectItem>
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-11 w-[150px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700"
title="排序"
>
<ArrowDownUpIcon className="mr-1 size-4 text-slate-500" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">更新时间</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
{activeTab === "private" && privatePassword && ( {activeTab === "private" && privatePassword && (
<Button <Button
variant="outline" variant="outline"
@@ -432,48 +497,16 @@ export function AgentGallery() {
锁定 锁定
</Button> </Button>
)} )}
</div> </div>
)}
</div> </div>
{showSearchBar && (
<TagSearchBar
// Remount per tab so the internal keyword input / 展开 state also
// resets when the parent-owned filters are cleared on tab switch.
key={activeTab}
targetType="agent"
searching={
activeTab === "private" ? isPrivateFetching : mainFetching
}
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
)}
{publisher && ( {publisher && (
<div className="flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span> <span className="text-muted-foreground text-xs">筛选发布者:</span>
<button <button
type="button" type="button"
onClick={() => setPublisher(null)} onClick={() => 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="bg-primary/5 text-primary hover:bg-primary/10 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors dark:bg-primary/15 dark:text-primary"
title="点击清除发布者筛选" title="点击清除发布者筛选"
> >
{publisher.label} {publisher.label}
@@ -481,13 +514,16 @@ export function AgentGallery() {
</button> </button>
</div> </div>
)} )}
<div className="py-4 text-[15px] text-slate-600 dark:text-slate-300">
共 {activeTotal} 个智能体
</div>
</div> </div>
{squareTabs.map((sq) => ( {squareTabs.map((sq) => (
<TabsContent <TabsContent
key={sq.id} key={sq.id}
value={sq.id} value={sq.id}
className="flex-1 overflow-y-auto p-6 mt-0" className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8"
> >
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
@@ -500,7 +536,7 @@ export function AgentGallery() {
</TabsContent> </TabsContent>
))} ))}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="mine" className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8">
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
total: mainTotal, total: mainTotal,
@@ -513,7 +549,7 @@ export function AgentGallery() {
</TabsContent> </TabsContent>
{isAdmin && ( {isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="builtin" className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8">
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
total: mainTotal, total: mainTotal,
@@ -528,7 +564,7 @@ export function AgentGallery() {
{privateFeatureAvailable && ( {privateFeatureAvailable && (
<TabsContent <TabsContent
value="private" value="private"
className="flex-1 overflow-y-auto p-6 mt-0" className="mt-0 flex-1 overflow-y-auto px-5 pb-6 sm:px-8"
> >
{renderPrivateTab()} {renderPrivateTab()}
</TabsContent> </TabsContent>
@@ -14,6 +14,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { env } from "@/env";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks"; import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -26,9 +27,10 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
*/ */
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content"; export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark) // 换肤色板:红色=红白主题(light),绿色=WeKnora 绿色主题(lightgreen),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [ export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
{ id: "light", label: "红色", color: "#cf1322" }, { id: "light", label: "红色", color: "#cf1322" },
{ id: "lightgreen", label: "绿色", color: "#07c05f" },
{ id: "lightblue", label: "蓝色", color: "#0062d9" }, { id: "lightblue", label: "蓝色", color: "#0062d9" },
{ id: "dark", label: "深蓝色", color: "#002466" }, { id: "dark", label: "深蓝色", color: "#002466" },
]; ];
@@ -78,6 +80,8 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
} }
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
if (env.FORCE_COLLAPSED_SIDEBAR) return null;
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -180,6 +184,8 @@ export function LayoutModeSwitcher({
fallback?: "full" | "compact"; fallback?: "full" | "compact";
navigateTo?: string | null; navigateTo?: string | null;
}) { }) {
if (env.FORCE_COLLAPSED_SIDEBAR) return null;
const [mode, setLayoutMode] = useLayoutMode(); const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate(); const navigate = useNavigate();
const current = mode ?? fallback; const current = mode ?? fallback;
@@ -0,0 +1,220 @@
"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 { useTheme } from "@/contexts/ThemeContext";
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,
forceCollapsed = false,
}: {
onCollapse?: () => void;
collapsed?: boolean;
/** Deployment-level compact navigation: retain only accessible menu icons. */
forceCollapsed?: boolean;
}) {
const pathname = usePathname();
const navigate = useNavigate();
const { theme } = useTheme();
const brandLogoSrc = theme === "light" ? "/cm-assistant-logo-red.png" : "/cm-assistant-logo.png";
const handleNewChat = (event: MouseEvent<HTMLAnchorElement>) => {
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 (
<aside className="flex h-full min-h-0 w-14 flex-col border-r border-slate-200/80 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
{!forceCollapsed && (
<>
<button
type="button"
onClick={onCollapse}
aria-label="展开侧边栏"
title="展开侧边栏"
className="mx-auto mt-2 flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900"
>
<img
src={brandLogoSrc}
alt="CM 助手"
className="size-10 object-contain"
/>
</button>
<div className="mx-2 mt-2 h-px shrink-0 bg-slate-200/80 dark:bg-slate-800" />
</>
)}
<nav
className={cn(
"flex shrink-0 flex-col items-center gap-1 px-2 py-3",
forceCollapsed && "pt-4",
)}
aria-label="工作台导航"
>
{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 (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
aria-label={item.label}
title={item.label}
className={cn(
"flex size-10 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
active
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[19px]" strokeWidth={1.8} />
)}
<span className="sr-only">{item.label}</span>
</Link>
);
})}
</nav>
<div className="mt-auto flex shrink-0 justify-center border-t border-slate-200/80 py-3 dark:border-slate-800">
<Link
href="/page/workspace/settings"
aria-label="设置和更多"
title="设置和更多"
className="flex size-10 items-center justify-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
>
<Settings className="size-[19px]" strokeWidth={1.8} />
</Link>
</div>
</aside>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden border-r border-slate-200/80 bg-white text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
<div className="flex shrink-0 items-center gap-1.5 px-4 pt-3.5 pb-2.5">
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-primary/40">
<img
src={brandLogoSrc}
alt="CM 助手"
className="size-9 shrink-0 object-contain"
/>
<span className="truncate text-[19px] font-semibold tracking-[-0.025em]">CM 助手</span>
</Link>
<button
type="button"
onClick={onCollapse}
aria-label="收起侧边栏"
title="收起侧边栏"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900 dark:hover:text-white"
>
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
</button>
</div>
<nav className="shrink-0 px-3 pt-1" aria-label="工作台导航">
<div className="space-y-1">
{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 (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[14px] font-medium transition-colors",
active
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[18px] shrink-0" strokeWidth={1.8} />
)}
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-1">
<RecentChatList compact limit={5} moreHref="/page/workspace/chats" />
</div>
<div className="shrink-0 border-t border-slate-200/80 px-3 py-3 dark:border-slate-800">
<WorkspaceNavMenu />
</div>
</div>
);
}
@@ -11,38 +11,15 @@ export function CompactWelcome({ className }: { className?: string }) {
return ( return (
<div <div
className={cn( className={cn(
"mx-auto flex w-full flex-col items-center justify-center text-center select-none", "mx-auto flex w-full flex-col items-center justify-center text-center",
className, className,
)} )}
> >
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */} <h1 className="flex items-center justify-center text-[34px] font-semibold tracking-[-0.035em] text-slate-950 dark:text-slate-50">
<div 你好,欢迎使用 CM 助手
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
<div
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
style={{
backgroundImage:
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
}}
>
<span className="text-2xl font-black tracking-wide text-white italic">
CM
</span>
</div>
</div>
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
欢迎使用CM助手
</h1> </h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed"> <p className="mx-auto mt-2.5 max-w-xl text-[15px] leading-6 text-slate-500 dark:text-slate-400">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。 我可以帮你写作、问答、分析和规划各类内容,让工作更高效
</p> </p>
</div> </div>
); );
@@ -46,10 +46,12 @@ export function CustomPromptBar({
onInsert, onInsert,
className, className,
currentAgentId, currentAgentId,
compact = false,
}: { }: {
onInsert: (content: string) => void; onInsert: (content: string) => void;
className?: string; className?: string;
currentAgentId?: string | null; currentAgentId?: string | null;
compact?: boolean;
}) { }) {
const allPrompts = useCustomPrompts(); const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter( const prompts = allPrompts.filter(
@@ -81,7 +83,13 @@ export function CustomPromptBar({
return ( return (
<> <>
<div className={cn("flex min-w-0 items-center gap-1", className)}> <div
className={cn(
"flex min-w-0 items-center gap-1",
compact && "min-h-9 justify-end",
className,
)}
>
{prompts.length > 0 && ( {prompts.length > 0 && (
<> <>
{overflowing && ( {overflowing && (
@@ -111,6 +119,7 @@ export function CustomPromptBar({
title={prompt.content} title={prompt.content}
className={cn( 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", "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-primary/25 hover:bg-primary/5 hover:text-primary",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-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" type="button"
variant="ghost" variant="ghost"
size="icon" 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-primary/25 hover:bg-primary/5 hover:text-primary dark:border-slate-700 dark:bg-slate-900",
)}
title="管理常用提示词" title="管理常用提示词"
aria-label="管理常用提示词" aria-label="管理常用提示词"
onClick={() => setManageOpen(true)} onClick={() => setManageOpen(true)}
@@ -2,14 +2,18 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BarChart3Icon,
BrainCircuitIcon, BrainCircuitIcon,
BookMarkedIcon, BookMarkedIcon,
CheckIcon, CheckIcon,
ClipboardCheckIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
LightbulbIcon, LightbulbIcon,
MessageCircleQuestionIcon,
PaperclipIcon, PaperclipIcon,
PlusIcon, PlusIcon,
RefreshCwIcon,
ShrinkIcon, ShrinkIcon,
SparklesIcon, SparklesIcon,
RocketIcon, RocketIcon,
@@ -104,6 +108,57 @@ 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( function getResolvedMode(
mode: InputMode | undefined, mode: InputMode | undefined,
@@ -211,6 +266,7 @@ export function InputBox({
const wasStreamingRef = useRef(false); const wasStreamingRef = useRef(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>( const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null, null,
); );
@@ -221,6 +277,29 @@ export function InputBox({
!hideAdminRecommendedQuestions && !hideAdminRecommendedQuestions &&
!userPreferences.hide_admin_prompt_prefix && !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(() => { useEffect(() => {
if (models.length === 0) { if (models.length === 0) {
@@ -256,6 +335,7 @@ export function InputBox({
() => selectedModel?.supports_thinking ?? false, () => selectedModel?.supports_thinking ?? false,
[selectedModel], [selectedModel],
); );
const effectiveMode = getResolvedMode(context.mode, supportThinking);
const supportReasoningEffort = useMemo( const supportReasoningEffort = useMemo(
() => selectedModel?.supports_reasoning_effort ?? false, () => selectedModel?.supports_reasoning_effort ?? false,
@@ -529,14 +609,68 @@ export function InputBox({
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]); }, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
return ( return (
<div ref={promptRootRef} className="relative flex flex-col gap-6"> <div
ref={promptRootRef}
className={cn(
"relative flex flex-col",
compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6",
)}
>
{extraHeader && ( {extraHeader && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
{extraHeader} {extraHeader}
</div> </div>
)} )}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( {isNewThread && searchParams.get("mode") !== "skill" && compact && (
<section className="px-2" aria-labelledby="recommended-prompts-title">
<div className="mb-3 flex items-center justify-center gap-1.5">
<h2
id="recommended-prompts-title"
className="text-[14px] font-normal text-slate-400 dark:text-slate-500"
>
你可以这样问我
</h2>
<button
type="button"
data-prompt-order-refresh="true"
title="调整提示词顺序"
aria-label="调整提示词顺序"
onClick={() => 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-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:hover:bg-slate-800 dark:hover:text-primary"
>
<RefreshCwIcon className="size-3.5" strokeWidth={1.8} />
</button>
</div>
<div className="mx-auto flex max-w-[900px] flex-wrap justify-center gap-x-3 gap-y-3">
{orderedCompactQuickStarts.map((item) => {
return (
<button
key={item.id}
type="button"
data-quick-prompt={item.id}
title={item.content}
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-xl border border-[#e5e7eb] bg-white px-4 py-2 text-center text-[14px] font-normal leading-5 text-slate-700 shadow-[0_2px_6px_rgba(15,23,42,0.025)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/5 hover:text-slate-900 hover:shadow-[0_4px_10px_rgba(15,23,42,0.04)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-primary/35 dark:hover:bg-primary/10"
onClick={() => {
textInput.setInput(item.content);
requestAnimationFrame(() =>
document
.querySelector<HTMLTextAreaElement>(
"textarea[name='message']",
)
?.focus(),
);
}}
>
<span className="max-w-full whitespace-normal">{item.title}</span>
</button>
);
})}
</div>
</section>
)}
{isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]"> <div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => ( {recommendedQuestions.map((q) => (
<button <button
@@ -589,7 +723,8 @@ export function InputBox({
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{isNewThread && ( {isNewThread && (
<CustomPromptBar <CustomPromptBar
className="px-1" compact={compact}
className={cn(compact ? "px-2" : "px-1")}
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null} currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
onInsert={(content) => { onInsert={(content) => {
const current = textInput.value?.trim() ?? ""; const current = textInput.value?.trim() ?? "";
@@ -613,7 +748,7 @@ export function InputBox({
: "*:data-[slot='input-group']:rounded-3xl", : "*:data-[slot='input-group']:rounded-3xl",
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边) // 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
compact && isNewThread 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" ? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(15,23,42,0.22),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-primary/35 focus-within:ring-4 focus-within:ring-primary/10 dark:border-slate-700 dark:bg-slate-800"
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边 : // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影 // 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread isNewThread
@@ -637,7 +772,8 @@ export function InputBox({
className={cn( className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60", "size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
isNewThread && "min-h-[110px]!", 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} disabled={disabled}
placeholder={t.inputBox.placeholder} placeholder={t.inputBox.placeholder}
@@ -648,7 +784,7 @@ export function InputBox({
<PromptInputFooter <PromptInputFooter
className={cn( className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5", "flex items-center border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3", compact && "gap-2 px-6 pt-2 pb-5",
)} )}
> >
<PromptInputTools <PromptInputTools
@@ -708,7 +844,7 @@ export function InputBox({
"gap-1.5! rounded-full px-3! shadow-none", "gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium", compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25" ? "border border-primary/35 bg-primary/5 text-primary ring-1 ring-primary/20 hover:bg-primary/10 dark:border-primary/45 dark:bg-primary/15 dark:text-primary dark:ring-primary/25 dark:hover:bg-primary/20"
: compact : compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground" ? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", : "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
@@ -716,7 +852,7 @@ export function InputBox({
onClick={handleMemoryInjectionToggle} onClick={handleMemoryInjectionToggle}
> >
{memoryInjectionEnabled && ( {memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" /> <CheckIcon className="text-primary size-3" />
)} )}
<BrainCircuitIcon className="size-3" /> <BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span> <span className="text-xs font-normal">记忆</span>
@@ -784,12 +920,7 @@ export function InputBox({
<PromptInputActionMenu> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
context.mode === "flash" || effectiveMode
context.mode === "thinking" ||
context.mode === "pro" ||
context.mode === "ultra"
? context.mode
: "flash"
} }
> >
<PromptInputActionMenuTrigger <PromptInputActionMenuTrigger
@@ -797,38 +928,38 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium", compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact && compact &&
(context.mode === "thinking" (effectiveMode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200" ? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
: context.mode === "pro" : effectiveMode === "pro"
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200" ? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: context.mode === "ultra" : effectiveMode === "ultra"
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200" ? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"), : "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
)} )}
> >
<div> <div>
{context.mode === "flash" && <ZapIcon className="size-3" />} {effectiveMode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && ( {effectiveMode === "thinking" && (
<LightbulbIcon className="size-3" /> <LightbulbIcon className="size-3" />
)} )}
{context.mode === "pro" && ( {effectiveMode === "pro" && (
<GraduationCapIcon className="size-3" /> <GraduationCapIcon className="size-3" />
)} )}
{context.mode === "ultra" && ( {effectiveMode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" /> <RocketIcon className="size-3 text-[#dabb5e]" />
)} )}
</div> </div>
<div <div
className={cn( className={cn(
"text-xs font-normal", "text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "", effectiveMode === "ultra" ? "golden-text" : "",
)} )}
> >
{(context.mode === "flash" && t.inputBox.flashMode) || {(effectiveMode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" && (effectiveMode === "thinking" &&
t.inputBox.reasoningMode) || t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) || (effectiveMode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)} (effectiveMode === "ultra" && t.inputBox.ultraMode)}
</div> </div>
</PromptInputActionMenuTrigger> </PromptInputActionMenuTrigger>
</ModeHoverGuide> </ModeHoverGuide>
@@ -1125,6 +1256,8 @@ export function InputBox({
> >
<ModelSelectorTrigger asChild> <ModelSelectorTrigger asChild>
<PromptInputButton <PromptInputButton
disabled={models.length === 0}
aria-label="模型选择"
className={cn( className={cn(
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && compact &&
@@ -1138,7 +1271,7 @@ export function InputBox({
compact && "text-[11px]", compact && "text-[11px]",
)} )}
> >
{selectedModel?.display_name} {selectedModel?.display_name || "默认模型"}
</ModelSelectorName> </ModelSelectorName>
</div> </div>
</PromptInputButton> </PromptInputButton>
@@ -1170,9 +1303,9 @@ export function InputBox({
</ModelSelector> </ModelSelector>
<PromptInputSubmit <PromptInputSubmit
className={cn( className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none", "rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-primary dark:border-primary dark:text-primary-foreground dark:hover:bg-primary/90 dark:shadow-none",
compact && compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:opacity-90 dark:disabled:bg-muted", "bg-primary hover:bg-primary/90 size-10 rounded-full border-0 text-primary-foreground shadow-[0_8px_18px_-10px_rgba(15,23,42,0.34)] disabled:bg-muted disabled:text-muted-foreground/40 dark:border-0 dark:bg-primary dark:text-primary-foreground dark:hover:bg-primary/90 dark:disabled:bg-muted",
)} )}
disabled={disabled} disabled={disabled}
variant="outline" variant="outline"
@@ -65,7 +65,10 @@ import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts"; import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources"; import {
isWeKnoraSourcePreviewUrl,
SourceCard,
} from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
@@ -651,6 +654,30 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
sourceLabel = sourceLabel ?? item.url; sourceLabel = sourceLabel ?? item.url;
} }
} }
// MCP retrieval tools without a configured display adapter fall back to this
// generic result card. Preserve the normal web-search UI, but route our
// credential-free WeKnora pointer through SourceCard so its dialog exposes a
// deliberate “查看原文” action rather than rendering the internal route text.
if (isWeKnoraSourcePreviewUrl(item.url)) {
const weknoraSource: RuntimeDisplaySource = {
id: item.id ?? item.uuid ?? item.docId,
title: item.title ?? "检索结果",
type: item.source ?? "document",
snippet: item.snippet ?? item.content ?? "",
content,
source: item.source,
author: item.author,
time: date,
url: item.url,
index: 0,
skillName: "weknora-knowledge-search",
mode: "list",
raw: { ...item },
};
return <SourceCard source={weknoraSource} />;
}
return ( return (
<> <>
<button <button
@@ -7,6 +7,7 @@ import {
ExternalLink, ExternalLink,
LayoutGridIcon, LayoutGridIcon,
LayoutIcon, LayoutIcon,
LeafIcon,
ListIcon, ListIcon,
MoonIcon, MoonIcon,
PanelLeftIcon, PanelLeftIcon,
@@ -70,7 +71,7 @@ const languageOptions: { value: Locale; label: string }[] = [
{ value: "zh-CN", label: zhCN.locale.localName }, { value: "zh-CN", label: zhCN.locale.localName },
]; ];
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue"; const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() { function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) { if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
@@ -212,13 +213,19 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
}; };
const handleSetGlobalDefault = async ( const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue", value: "light" | "dark" | "lightblue" | "lightgreen",
) => { ) => {
if (value === globalDefault) return; if (value === globalDefault) return;
try { try {
await updateAppearance.mutateAsync({ default_theme: value }); await updateAppearance.mutateAsync({ default_theme: value });
const label = const label =
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝"; value === "light"
? "浅色"
: value === "dark"
? "深色"
: value === "lightgreen"
? "绿色"
: "浅蓝";
toast.success(`已将系统默认主题设置为${label}`); toast.success(`已将系统默认主题设置为${label}`);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败"); toast.error(e instanceof Error ? e.message : "保存失败");
@@ -374,6 +381,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
description: t.settings.appearance.lightblueDescription, description: t.settings.appearance.lightblueDescription,
icon: DropletIcon, icon: DropletIcon,
}, },
{
id: "lightgreen",
label: t.settings.appearance.lightgreen,
description: t.settings.appearance.lightgreenDescription,
icon: LeafIcon,
},
], ],
[ [
t.settings.appearance.dark, t.settings.appearance.dark,
@@ -382,6 +395,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
t.settings.appearance.lightDescription, t.settings.appearance.lightDescription,
t.settings.appearance.lightblue, t.settings.appearance.lightblue,
t.settings.appearance.lightblueDescription, t.settings.appearance.lightblueDescription,
t.settings.appearance.lightgreen,
t.settings.appearance.lightgreenDescription,
], ],
); );
@@ -399,7 +414,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
label={option.label} label={option.label}
description={option.description} description={option.description}
active={currentTheme === option.id} active={currentTheme === option.id}
mode={option.id as "light" | "dark" | "lightblue"} mode={option.id as "light" | "dark" | "lightblue" | "lightgreen"}
onSelect={(value) => setTheme(value)} onSelect={(value) => setTheme(value)}
/> />
))} ))}
@@ -551,13 +566,25 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<DropletIcon className="mr-1.5 size-4" /> <DropletIcon className="mr-1.5 size-4" />
浅蓝 浅蓝
</Button> </Button>
<Button
type="button"
variant={globalDefault === "lightgreen" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightgreen")}
disabled={updateAppearance.isPending}
>
<LeafIcon className="mr-1.5 size-4" />
绿色
</Button>
<p className="text-muted-foreground ml-2 text-xs"> <p className="text-muted-foreground ml-2 text-xs">
当前系统默认: 当前系统默认:
{globalDefault === "light" {globalDefault === "light"
? "浅色" ? "浅色"
: globalDefault === "dark" : globalDefault === "dark"
? "深色" ? "深色"
: "浅蓝"} : globalDefault === "lightgreen"
? "绿色"
: "浅蓝"}
</p> </p>
</div> </div>
</SettingsSection> </SettingsSection>
@@ -1074,8 +1101,8 @@ function ThemePreviewCard({
label: string; label: string;
description: string; description: string;
active: boolean; active: boolean;
mode: "light" | "dark" | "lightblue"; mode: "light" | "dark" | "lightblue" | "lightgreen";
onSelect: (mode: "light" | "dark" | "lightblue") => void; onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen") => void;
}) { }) {
const previewMode = mode; const previewMode = mode;
return ( return (
@@ -1107,6 +1134,8 @@ function ThemePreviewCard({
? "border-neutral-800 bg-neutral-900 text-neutral-200" ? "border-neutral-800 bg-neutral-900 text-neutral-200"
: previewMode === "lightblue" : previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900" ? "border-blue-200 bg-white text-slate-900"
: previewMode === "lightgreen"
? "border-emerald-200 bg-white text-emerald-950"
: "border-slate-200 bg-white text-slate-900", : "border-slate-200 bg-white text-slate-900",
)} )}
> >
@@ -1118,7 +1147,9 @@ function ThemePreviewCard({
? "bg-emerald-400" ? "bg-emerald-400"
: previewMode === "lightblue" : previewMode === "lightblue"
? "bg-blue-500" ? "bg-blue-500"
: "bg-emerald-500", : previewMode === "lightgreen"
? "bg-[#07c05f]"
: "bg-[#cf1322]",
)} )}
/> />
<div className="h-2 w-10 rounded-full bg-current/20" /> <div className="h-2 w-10 rounded-full bg-current/20" />
@@ -26,6 +26,7 @@ import {
TrendingUpIcon, TrendingUpIcon,
Upload, Upload,
UserIcon, UserIcon,
WorkflowIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -50,13 +51,6 @@ import {
EmptyTitle, EmptyTitle,
} from "@/components/ui/empty"; } from "@/components/ui/empty";
import {
Item,
ItemActions,
ItemTitle,
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -744,7 +738,7 @@ export function SkillRow({
const badgeGroup = ( const badgeGroup = (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={cn(isCard && "truncate font-medium")}> <span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)} {skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
</span> </span>
{skill.name_zh?.trim() && ( {skill.name_zh?.trim() && (
@@ -768,7 +762,7 @@ export function SkillRow({
{isFromOthers && ( {isFromOthers && (
<Badge <Badge
variant="outline" variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs" className="border-primary/30 text-primary text-xs dark:text-primary"
title={ title={
skill.owner_user_id skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace( ? t.settings.skills.badgeFromOthersTitle.replace(
@@ -907,7 +901,7 @@ export function SkillRow({
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8 shrink-0" className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
onClick={() => setTagPickerOpen(true)} onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags} title={t.adminTags.manageTags}
> >
@@ -918,7 +912,7 @@ export function SkillRow({
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.featured && "text-amber-600 hover:text-amber-700", skill.featured && "text-amber-600 hover:text-amber-700",
)} )}
disabled={setFeatured.isPending} disabled={setFeatured.isPending}
@@ -939,7 +933,7 @@ export function SkillRow({
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.pinned && "text-emerald-600 hover:text-emerald-700", skill.pinned && "text-emerald-600 hover:text-emerald-700",
)} )}
disabled={setPinned.isPending} disabled={setPinned.isPending}
@@ -960,7 +954,7 @@ export function SkillRow({
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.always_on && "text-amber-600 hover:text-amber-700", skill.always_on && "text-amber-600 hover:text-amber-700",
)} )}
disabled={setAlwaysOn.isPending} disabled={setAlwaysOn.isPending}
@@ -999,7 +993,7 @@ export function SkillRow({
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8 shrink-0" className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
disabled={duplicateSkillMutation.isPending} disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate} onClick={handleOpenDuplicate}
title="复制为新技能" title="复制为新技能"
@@ -1008,9 +1002,9 @@ export function SkillRow({
</Button> </Button>
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0" className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={() => setConfirmDeleteOpen(true)} onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction} title={t.settings.skills.deleteAction}
> >
@@ -1020,38 +1014,121 @@ 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-primary text-primary-foreground shadow-[0_6px_16px_rgba(15,23,42,0.18)]",
}
: {
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 = (
<div className="flex flex-wrap items-center justify-end gap-1" onClick={(event) => event.stopPropagation()}>
{actionButtons}
</div>
);
return ( return (
<> <>
{isCard ? ( {isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white"> <div className="flex h-full min-w-0 flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/[0.025] hover:shadow-[0_10px_28px_rgba(15,23,42,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<div <div
className="min-w-0 flex-1 cursor-pointer" className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
> >
{badgeGroup} <div className="flex items-start gap-3.5">
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm"> <div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
{desensitize(skill.description ?? "")} <SkillIcon className="size-6" />
</p> </div>
{metaNodes} <div className="min-w-0 flex-1">
{badgeGroup}
<p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
{desensitize(skill.description ?? "")}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
</div>
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
</div> </div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]"> <div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
{actionButtons} {actionArea}
</div> </div>
</div> </div>
) : ( ) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline"> <div className="group/skill-card w-full rounded-2xl border border-slate-200 bg-white px-5 py-3.5 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/[0.025] hover:shadow-[0_10px_28px_rgba(15,23,42,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<ItemContent <div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.55fr)_minmax(160px,0.78fr)_minmax(110px,0.42fr)_minmax(142px,0.56fr)_auto] xl:items-center xl:gap-x-5">
className="cursor-pointer" <div className="flex min-w-0 cursor-pointer items-start gap-3.5" onClick={() => setDetailOpen(true)}>
onClick={() => setDetailOpen(true)} <div className={cn("flex size-14 shrink-0 items-center justify-center rounded-2xl", iconStyle.className)}>
> <SkillIcon className="size-7" />
<ItemTitle>{badgeGroup}</ItemTitle> </div>
<ItemDescription className="line-clamp-4"> <div className="min-w-0 flex-1 pt-0.5">
{desensitize(skill.description ?? "")} {badgeGroup}
</ItemDescription> <p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
{metaNodes} {desensitize(skill.description ?? "")}
</ItemContent> </p>
<ItemActions>{actionButtons}</ItemActions> {!isBuiltin && skill.owner_username && (
</Item> <button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(event) => {
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-primary hover:underline",
)}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
</div>
</div>
<div className="min-w-0 xl:border-l xl:border-slate-100 xl:pl-5 xl:dark:border-slate-800">
{(skill.tags?.length ?? 0) > 0 ? (
<TagChipList tags={skill.tags!} />
) : (
<span className="text-xs text-slate-400">未设置标签</span>
)}
</div>
<div className="min-w-0 text-sm text-slate-600 dark:text-slate-300">
<span className="block text-xs text-slate-400 xl:hidden">使用次数</span>
{skill.use_count ? `${skill.use_count} 次` : "暂无调用"}
</div>
<div className="min-w-0">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
{activityAt && <p className="mt-2 truncate text-xs text-slate-400" title={activityAt}>更新于 {activityAt}</p>}
</div>
<div className="min-w-0 border-t border-slate-100 pt-3 xl:border-t-0 xl:pt-0 dark:border-slate-800">
{actionArea}
</div>
</div>
</div>
)} )}
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}> <Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent> <DialogContent>
@@ -53,6 +53,7 @@ export function SourceCard({
referenceTarget?: boolean; referenceTarget?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showOriginal, setShowOriginal] = useState(false);
const [context, setContext] = useState<WeKnoraSourceContext | null>(null); const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
const [contextLoading, setContextLoading] = useState(false); const [contextLoading, setContextLoading] = useState(false);
const [contextError, setContextError] = useState<string | null>(null); const [contextError, setContextError] = useState<string | null>(null);
@@ -81,7 +82,7 @@ export function SourceCard({
const previewTarget = parseWeKnoraPreviewUrl(source.url); const previewTarget = parseWeKnoraPreviewUrl(source.url);
useEffect(() => { useEffect(() => {
if (!open || !previewTarget) { if (!open || !showOriginal || !previewTarget) {
return; return;
} }
let cancelled = false; let cancelled = false;
@@ -115,7 +116,7 @@ export function SourceCard({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]); }, [open, showOriginal, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
return ( return (
<> <>
@@ -128,7 +129,10 @@ export function SourceCard({
highlighted && highlighted &&
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]", "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);
}}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{showReferenceNumber && ( {showReferenceNumber && (
@@ -168,7 +172,15 @@ export function SourceCard({
)} )}
</div> </div>
</button> </button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) {
setShowOriginal(false);
}
}}
>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8"> <div className="flex items-start justify-between gap-3 pr-8">
@@ -205,7 +217,27 @@ export function SourceCard({
)} )}
</div> </div>
</DialogHeader> </DialogHeader>
{previewTarget ? ( {previewTarget && (
<div className="border-b pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
{showOriginal
? "正在查看命中文段及其前后上下文"
: "当前为检索命中的片段,可继续查看原文上下文"}
</span>
<Button
type="button"
size="sm"
variant={showOriginal ? "outline" : "default"}
onClick={() => setShowOriginal((current) => !current)}
>
<FileTextIcon className="size-3.5" />
{showOriginal ? "返回检索片段" : "查看原文"}
</Button>
</div>
</div>
)}
{previewTarget && showOriginal ? (
<WeKnoraSourceContextPanel <WeKnoraSourceContextPanel
context={context} context={context}
loading={contextLoading} loading={contextLoading}
@@ -249,12 +281,27 @@ type WeKnoraSourceContext = {
}>; }>;
}; };
export function isWeKnoraSourcePreviewUrl(url?: string): boolean {
return parseWeKnoraPreviewUrl(url) !== null;
}
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | 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; return null;
} }
const queryIndex = url.indexOf("?"); // Some MCP tool results wrap the relative pointer in formatting characters
const params = new URLSearchParams(url.slice(queryIndex + 1)); // 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 knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim(); const chunkId = params.get("chunk_id")?.trim();
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null; return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
@@ -1,6 +1,6 @@
"use client"; "use client";
import { User } from "lucide-react"; import { UserRound } from "lucide-react";
import { useLiveAccount } from "@/core/auth/use-live-account"; import { useLiveAccount } from "@/core/auth/use-live-account";
import { useMyPosition } from "@/core/positions"; import { useMyPosition } from "@/core/positions";
@@ -15,7 +15,13 @@ import { cn } from "@/lib/utils";
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏, * 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
* 而非 Header 的白字贴 banner 方案。 * 而非 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 // userInfo belongs to external-app route parameters and can legitimately
// outlive a login switch. The visible account name must instead match the // outlive a login switch. The visible account name must instead match the
// settings page's current auth identity. // settings page's current auth identity.
@@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) {
return ( return (
<div <div
className={cn("flex shrink-0 items-center gap-2", className)} data-workspace-user={variant}
className={cn(
"flex shrink-0 items-center gap-2",
variant === "topbar" &&
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
className,
)}
title={positionName ? `${displayName}·${positionName}` : displayName} title={positionName ? `${displayName}·${positionName}` : displayName}
> >
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full"> <div
<User className="size-3.5" /> className={cn(
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
variant === "topbar" &&
"bg-primary text-primary-foreground size-8 shadow-[0_4px_12px_-6px_rgba(15,23,42,0.26)] ring-1 ring-primary/15",
)}
>
<UserRound className={cn("size-3.5", variant === "topbar" && "size-4")} />
{variant === "topbar" && (
<span className="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-950" />
)}
</div> </div>
<div className="hidden min-w-0 text-left sm:block"> <div
className={cn(
"hidden min-w-0 text-left sm:block",
variant === "topbar" && "max-w-[165px]",
)}
>
<p className="text-foreground truncate text-sm font-medium leading-tight"> <p className="text-foreground truncate text-sm font-medium leading-tight">
{displayName} {displayName}
</p> </p>
@@ -13,7 +13,10 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
export function WorkspaceContainer({ export function WorkspaceContainer({
className, className,
@@ -93,9 +96,19 @@ export function WorkspaceHeader({
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>
{rightActions ? ( <div className="flex items-center gap-2 px-4">
<div className="flex items-center gap-2 px-4">{rightActions}</div> {rightActions}
) : null} {!env.FORCE_COLLAPSED_SIDEBAR && (
<>
<ThemeSwitcher tone="content" />
<div
aria-hidden="true"
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
/>
</>
)}
<UserIdentityBadge variant="topbar" />
</div>
</header> </header>
); );
} }
@@ -125,5 +138,6 @@ function nameOfSegment(
if (!segment) return t.common.home; if (!segment) return t.common.home;
if (segment === "workspace") return t.breadcrumb.workspace; if (segment === "workspace") return t.breadcrumb.workspace;
if (segment === "chats") return t.breadcrumb.chats; if (segment === "chats") return t.breadcrumb.chats;
if (segment === "agents") return t.agents.title;
return segment[0]?.toUpperCase() + segment.slice(1); return segment[0]?.toUpperCase() + segment.slice(1);
} }
@@ -2,117 +2,51 @@
import { import {
Sidebar, Sidebar,
SidebarHeader,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar"; import { CmChatSidebar } from "./cm-chat-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({ export function WorkspaceSidebar({
compactChrome = false,
className, className,
compactChrome: _compactChrome,
forceCollapsed = false,
...props ...props
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) { }: React.ComponentProps<typeof Sidebar> & {
const { open: isSidebarOpen } = useSidebar(); compactChrome?: boolean;
const { theme } = useTheme(); forceCollapsed?: boolean;
const pathname = usePathname(); }) {
const useWhiteSidebar = compactChrome && theme === "light"; const { open: isSidebarOpen, toggleSidebar } = useSidebar();
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 return (
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对 <div
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从 aria-hidden={false}
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
aria-hidden={!isSidebarOpen}
className={cn( className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out", "h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none", isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
)} )}
> >
<Sidebar <Sidebar
variant="sidebar" variant="sidebar"
collapsible="none" collapsible="none"
className={cn( className={cn(
"h-full w-(--sidebar-width) border-r border-border", "h-full border-r border-border",
useWhiteSidebar && isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white", "[&_[data-slot=sidebar-container]]:bg-white [&_[data-slot=sidebar-inner]]:bg-white",
className, className,
)} )}
{...props} {...props}
> >
<SidebarContent> <SidebarContent>
{isGeneralQa ? ( <CmChatSidebar
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成 onCollapse={forceCollapsed ? undefined : toggleSidebar}
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。 collapsed={!isSidebarOpen}
<GeneralQaSidebar /> forceCollapsed={forceCollapsed}
) : ( />
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent> </SidebarContent>
<SidebarRail />
</Sidebar> </Sidebar>
</div> </div>
);
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
{isSidebarOpen && !compactChrome && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
); );
} }
+30 -4
View File
@@ -10,7 +10,7 @@ import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks'; import { useAppearanceSettings } from '@/core/system-settings/hooks';
// 主题类型 // 主题类型
export type ThemeType = 'dark' | 'light' | 'lightblue'; export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen';
// 主题配置 // 主题配置
export interface ThemeConfig { export interface ThemeConfig {
@@ -104,6 +104,28 @@ const themes: Record<ThemeType, ThemeConfig> = {
overlay: 'black/30', overlay: 'black/30',
}, },
}, },
lightgreen: {
type: 'lightgreen',
name: '绿色主题',
colors: {
background: 'white',
backgroundSecondary: 'emerald-50',
text: 'emerald-950',
textSecondary: 'emerald-800',
primary: 'emerald-600',
primaryHover: 'emerald-500',
border: 'emerald-200',
accent: 'emerald-700',
success: 'emerald-600',
warning: 'amber-500',
error: 'red-600',
card: 'white',
cardHover: 'emerald-50',
input: 'white',
inputBorder: 'emerald-200',
overlay: 'black/30',
},
},
}; };
const STORAGE_KEY = 'strategy-theme'; const STORAGE_KEY = 'strategy-theme';
@@ -121,6 +143,7 @@ function readPersistedTheme(): ThemeType {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark') return 'dark'; if (saved === 'dark') return 'dark';
if (saved === 'lightblue') return 'lightblue'; if (saved === 'lightblue') return 'lightblue';
if (saved === 'lightgreen') return 'lightgreen';
return 'light'; return 'light';
} }
@@ -179,7 +202,8 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
current && current &&
current !== 'light' && current !== 'light' &&
current !== 'dark' && current !== 'dark' &&
current !== 'lightblue' current !== 'lightblue' &&
current !== 'lightgreen'
) { ) {
// Garbage value — wipe it so the boot-sync below can take over. // Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
@@ -216,6 +240,8 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
? 'dark' ? 'dark'
: effectiveTheme === 'lightblue' : effectiveTheme === 'lightblue'
? 'lightblue' ? 'lightblue'
: effectiveTheme === 'lightgreen'
? 'lightgreen'
: 'light' : 'light'
: readPersistedTheme(); : readPersistedTheme();
@@ -234,8 +260,8 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
root.removeAttribute('theme-mode'); root.removeAttribute('theme-mode');
root.classList.remove('dark'); root.classList.remove('dark');
if (theme === 'lightblue') { if (theme === 'lightblue' || theme === 'lightgreen') {
root.setAttribute('data-theme', 'lightblue'); root.setAttribute('data-theme', theme);
} else { } else {
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
} }
+18 -2
View File
@@ -10,6 +10,7 @@
*/ */
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage"; import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
import type { ThemeType } from "@/contexts/ThemeContext";
export const EXTERNAL_LOGIN_STORAGE_KEYS = { export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket", ticket: "login.ticket",
@@ -58,6 +59,21 @@ export function readExternalLoginParams(): Required<ExternalLoginParams> {
* - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支) * - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支)
* 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。 * 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。
*/ */
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" { export function resolveThemeFromQuery(themeParam: string | null | undefined): ThemeType {
return themeParam === "dark-blue" ? "dark" : "light"; switch (themeParam?.trim().toLowerCase()) {
case "dark":
case "dark-blue":
case "darkblue":
return "dark";
case "blue":
case "lightblue":
case "light-blue":
return "lightblue";
case "green":
case "lightgreen":
case "light-green":
return "lightgreen";
default:
return "light";
}
} }
+11 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import type { ThemeType } from "@/contexts/ThemeContext";
/** /**
* 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。 * 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。
@@ -63,19 +64,25 @@ function readFromSearch(search: string): PublicBackground {
*/ */
export function normalizePublicTheme( export function normalizePublicTheme(
raw: string | null | undefined, raw: string | null | undefined,
): "dark" | "light" | undefined { ): ThemeType | undefined {
const value = raw?.trim().toLowerCase(); const value = raw?.trim().toLowerCase();
if (!value) return undefined; if (!value) return undefined;
if (value === "dark" || value === "dark-blue" || value === "深蓝" || value === "深色") { if (value === "dark" || value === "dark-blue" || value === "深蓝" || value === "深色") {
return "dark"; return "dark";
} }
if (value === "blue" || value === "lightblue" || value === "light-blue" || value === "浅蓝") {
return "lightblue";
}
if (value === "green" || value === "lightgreen" || value === "light-green" || value === "绿色") {
return "lightgreen";
}
if (value === "light" || value === "red" || value === "红白" || value === "浅色") { if (value === "light" || value === "red" || value === "红白" || value === "浅色") {
return "light"; return "light";
} }
return undefined; return undefined;
} }
function readThemeFromSearch(search: string): "dark" | "light" | undefined { function readThemeFromSearch(search: string): ThemeType | undefined {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`); const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
return normalizePublicTheme(params.get("theme")); return normalizePublicTheme(params.get("theme"));
} }
@@ -86,7 +93,7 @@ function readThemeFromSearch(search: string): "dark" | "light" | undefined {
* 与 `usePublicBackground` 同样合并两处查询串(hash 路由内优先), * 与 `usePublicBackground` 同样合并两处查询串(hash 路由内优先),
* postMessage 的动态值优先级最高。返回 undefined 表示外部未指定。 * postMessage 的动态值优先级最高。返回 undefined 表示外部未指定。
*/ */
export function usePublicEmbedTheme(): "dark" | "light" | undefined { export function usePublicEmbedTheme(): ThemeType | undefined {
const location = useLocation(); const location = useLocation();
const fromUrl = useMemo(() => { const fromUrl = useMemo(() => {
@@ -97,7 +104,7 @@ export function usePublicEmbedTheme(): "dark" | "light" | undefined {
return inner ?? outer; return inner ?? outer;
}, [location.search]); }, [location.search]);
const [override, setOverride] = useState<"dark" | "light" | undefined>(undefined); const [override, setOverride] = useState<ThemeType | undefined>(undefined);
useEffect(() => { useEffect(() => {
function onMessage(event: MessageEvent) { function onMessage(event: MessageEvent) {
+9 -2
View File
@@ -27,9 +27,11 @@
* 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。 * 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。
*/ */
import type { ThemeType } from "@/contexts/ThemeContext";
const EMBED_SESSION_KEY = "embed.session"; const EMBED_SESSION_KEY = "embed.session";
export type EmbedTheme = "light" | "dark"; export type EmbedTheme = ThemeType;
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */ /** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
export interface EmbedSession { export interface EmbedSession {
@@ -92,6 +94,8 @@ export function readIsolateParam(search: string): FlagParam {
export function resolveEmbedThemeMode(raw: string | null | undefined): EmbedTheme { export function resolveEmbedThemeMode(raw: string | null | undefined): EmbedTheme {
const v = (raw ?? "").trim().toLowerCase(); const v = (raw ?? "").trim().toLowerCase();
if (v === "dark" || v === "dark-blue" || v === "深色" || v === "深蓝") return "dark"; if (v === "dark" || v === "dark-blue" || v === "深色" || v === "深蓝") return "dark";
if (v === "blue" || v === "lightblue" || v === "light-blue" || v === "浅蓝") return "lightblue";
if (v === "green" || v === "lightgreen" || v === "light-green" || v === "绿色") return "lightgreen";
return "light"; return "light";
} }
@@ -108,7 +112,10 @@ export function readEmbedSession(): EmbedSession | null {
const p = JSON.parse(raw) as Partial<EmbedSession>; const p = JSON.parse(raw) as Partial<EmbedSession>;
return { return {
embed: p.embed === true, embed: p.embed === true,
theme: p.theme === "dark" ? "dark" : "light", theme:
p.theme === "dark" || p.theme === "lightblue" || p.theme === "lightgreen"
? p.theme
: "light",
hideJump: p.hideJump === true, hideJump: p.hideJump === true,
hideChrome: p.hideChrome === true, hideChrome: p.hideChrome === true,
isolate: p.isolate === true, isolate: p.isolate === true,
+5 -2
View File
@@ -1,4 +1,5 @@
import { resolveThemeFromQuery } from "@/core/auth/external-login"; import { resolveThemeFromQuery } from "@/core/auth/external-login";
import type { ThemeType } from "@/contexts/ThemeContext";
const DEFAULT_USERNAME = "guest"; const DEFAULT_USERNAME = "guest";
@@ -41,7 +42,7 @@ export function resolveEmbedChatParams(search: string): EmbedChatParams {
return { sessionId, message, username, theme }; return { sessionId, message, username, theme };
} }
export function resolveEmbedTheme(themeParam: string): "dark" | "light" { export function resolveEmbedTheme(themeParam: string): ThemeType {
return resolveThemeFromQuery(themeParam || null); return resolveThemeFromQuery(themeParam || null);
} }
@@ -50,8 +51,10 @@ export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。 * 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。 * 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
*/ */
export function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" { export function resolveEmbedRoundtableTheme(raw: string): ThemeType {
const v = raw.trim().toLowerCase(); const v = raw.trim().toLowerCase();
if (v === "light" || v === "red" || v === "红白") return "light"; if (v === "light" || v === "red" || v === "红白") return "light";
if (v === "blue" || v === "lightblue" || v === "light-blue" || v === "浅蓝") return "lightblue";
if (v === "green" || v === "lightgreen" || v === "light-green" || v === "绿色") return "lightgreen";
return "dark"; return "dark";
} }
+4 -2
View File
@@ -448,11 +448,13 @@ export const enUS: Translations = {
system: "System", system: "System",
light: "Light", light: "Light",
dark: "Dark", dark: "Dark",
lightblue: "Light Blue", lightblue: "Light Blue",
lightgreen: "Green",
systemDescription: "Match the operating system preference automatically.", systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.", lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.", darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.", lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around WeKnora green.",
languageTitle: "Language", languageTitle: "Language",
languageDescription: "Switch between languages.", languageDescription: "Switch between languages.",
}, },
+4 -2
View File
@@ -383,11 +383,13 @@ export interface Translations {
system: string; system: string;
light: string; light: string;
dark: string; dark: string;
lightblue: string; lightblue: string;
lightgreen: string;
systemDescription: string; systemDescription: string;
lightDescription: string; lightDescription: string;
darkDescription: string; darkDescription: string;
lightblueDescription: string; lightblueDescription: string;
lightgreenDescription: string;
languageTitle: string; languageTitle: string;
languageDescription: string; languageDescription: string;
}; };
+4 -2
View File
@@ -429,11 +429,13 @@ export const zhCN: Translations = {
system: "系统", system: "系统",
light: "浅色", light: "浅色",
dark: "深色", dark: "深色",
lightblue: "浅蓝", lightblue: "浅蓝",
lightgreen: "绿色",
systemDescription: "自动跟随系统主题。", systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。", lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。", darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。", lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
languageTitle: "语言", languageTitle: "语言",
languageDescription: "在不同语言之间切换。", languageDescription: "在不同语言之间切换。",
}, },
+1 -1
View File
@@ -276,7 +276,7 @@ export async function updatePublishSquares(
return data; return data;
} }
export type ThemeMode = "light" | "dark" | "lightblue"; export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen";
export type ViewMode = "card" | "list"; export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact"; export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme"; export type CompactShortcutParamType = "login" | "theme";
+6
View File
@@ -29,5 +29,11 @@ export const env = {
NEXT_PUBLIC_NOTE_API_BASE_URL: readRuntimeEnv("VITE_NOTE_API_BASE_URL"), NEXT_PUBLIC_NOTE_API_BASE_URL: readRuntimeEnv("VITE_NOTE_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readRuntimeEnv("VITE_AIPI_BACKENP_URL"), NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readRuntimeEnv("VITE_AIPI_BACKENP_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readRuntimeEnv("VITE_STATIC_WEBSITE_ONLY"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readRuntimeEnv("VITE_STATIC_WEBSITE_ONLY"),
/**
* Deployment-level compact navigation mode. This is intentionally a runtime
* flag so an operations deployment can switch the shell without rebuilding
* the frontend bundle.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"), GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
}; };
+11 -1
View File
@@ -1,5 +1,15 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery"; import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
export default function AgentsPage() { export default function AgentsPage() {
return <AgentGallery />; return (
<WorkspaceContainer className="bg-white dark:bg-slate-950">
<WorkspaceBody>
<AgentGallery />
</WorkspaceBody>
</WorkspaceContainer>
);
} }
+26 -32
View File
@@ -35,7 +35,6 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge"; import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-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 { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -51,7 +50,6 @@ import {
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log"; import { OptType, writeYLog } from "@/core/log/y-log";
@@ -103,8 +101,6 @@ export default function ChatPage() {
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } = const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar(); useSidebar();
const { t } = useI18n(); const { t } = useI18n();
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
const [effectiveLayoutMode] = useLayoutMode();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -444,20 +440,8 @@ export default function ChatPage() {
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined; : undefined;
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画; // 正常工作区统一使用新版侧栏和落地页;iframe 专用路由继续沿用嵌入布局。
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 —— const compactChrome = !isChatsIframeWorkspacePath(pathname);
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
const layoutMode = effectiveLayoutMode;
const chromeByPreference =
layoutMode === "full"
? true
: layoutMode === "compact"
? false
: readSidebarEnabled();
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome; const compactLanding = isLandingState && compactChrome;
return ( return (
@@ -470,16 +454,12 @@ export default function ChatPage() {
compactChrome && "workspace-chat-bg", compactChrome && "workspace-chat-bg",
)} )}
> >
<header {(!isLandingState || compactChrome) && (
className={cn( <header className="bg-background border-border/80 absolute top-0 right-0 left-0 z-30 flex h-14 shrink-0 items-center border-b px-5">
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium"> <div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} /> {!isLandingState && (
<ThreadTitle threadId={threadId} thread={thread} />
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */} {/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
@@ -507,7 +487,20 @@ export default function ChatPage() {
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */} 与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角, {/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */} 与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
{!compactChrome && ( {compactChrome ? (
<>
{!env.FORCE_COLLAPSED_SIDEBAR && (
<>
<ThemeSwitcher tone="content" />
<div
className="bg-border/70 mx-1 h-6 w-px shrink-0"
aria-hidden
/>
</>
)}
<UserIdentityBadge variant="topbar" />
</>
) : (
<> <>
<CompactShortcutButtons tone="content" /> <CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
@@ -521,6 +514,7 @@ export default function ChatPage() {
)} )}
</div> </div>
</header> </header>
)}
<main className="flex min-h-0 max-w-full grow flex-col"> <main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
{/* {/*
@@ -556,7 +550,7 @@ export default function ChatPage() {
"pointer-events-none absolute z-30 flex justify-center px-4", "pointer-events-none absolute z-30 flex justify-center px-4",
// 紧凑落地态:整块垂直+水平居中于对话区 // 紧凑落地态:整块垂直+水平居中于对话区
compactLanding 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", : "right-0 bottom-0 left-0",
)} )}
> >
@@ -568,7 +562,7 @@ export default function ChatPage() {
!compactLanding && !compactLanding &&
"-translate-y-[calc(50vh-96px)]", "-translate-y-[calc(50vh-96px)]",
compactLanding compactLanding
? "max-w-(--container-width-xl)" ? "max-w-[1024px]"
: isLandingState : isLandingState
? "max-w-(--container-width-md)" ? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)", : "max-w-(--container-width-lg)",
@@ -587,7 +581,7 @@ export default function ChatPage() {
</div> </div>
{isLandingState && mounted && {isLandingState && mounted &&
(compactLanding ? ( (compactLanding ? (
<CompactWelcome className="mb-6 -translate-y-4" /> <CompactWelcome className="mb-14" />
) : ( ) : (
<Welcome <Welcome
mode={settings.context.mode} mode={settings.context.mode}
@@ -595,7 +589,7 @@ export default function ChatPage() {
/> />
))} ))}
{mounted ? ( {mounted ? (
<div className="w-full -translate-y-4"> <div className={cn("w-full", !compactLanding && "-translate-y-4")}>
<InputBox <InputBox
className="w-full" className="w-full"
isNewThread={isLandingState} isNewThread={isLandingState}
-2
View File
@@ -18,7 +18,6 @@ import { ImportConversationsDialog } from "@/components/workspace/thread-share-d
import { import {
CompactShortcutButtons, CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
import { import {
WorkspaceBody, WorkspaceBody,
@@ -185,7 +184,6 @@ export default function ChatsPage({
compactChrome ? ( compactChrome ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" /> <CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
</div> </div>
) : null ) : null
@@ -19,9 +19,15 @@ export default function LightAppIframeView() {
// 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、 // 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。 // 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
// 深色=dark-blue,浅蓝=blue,其余(红白)=red。 // 深色=dark-blue,绿色=green,浅蓝=blue,其余(红白)=red。
const themeColor: AppThemeColor = const themeColor: AppThemeColor =
theme === "dark" ? "dark-blue" : theme === "lightblue" ? "blue" : "red"; theme === "dark"
? "dark-blue"
: theme === "lightgreen"
? "green"
: theme === "lightblue"
? "blue"
: "red";
const src = useMemo( const src = useMemo(
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""), () => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
[app, themeColor], [app, themeColor],
+46 -12
View File
@@ -34,7 +34,7 @@ import { toast } from "sonner";
import { Pagination } from "@/components/ui/pagination"; import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps"; import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -64,12 +64,46 @@ function hashIndex(str: string, mod: number): number {
return mod > 0 ? h % mod : 0; return mod > 0 ? h % mod : 0;
} }
function getLightAccent(theme: ThemeType) {
if (theme === "lightgreen") {
return {
dot: "bg-[#07C05F]",
badge: "border-[#07C05F]/15 bg-[#E9F8EC] text-[#049B38]",
focus: "focus:border-[#07C05F]/60 focus:ring-[#07C05F]/20",
text: "text-[#049B38]",
hoverText: "group-hover:text-[#049B38]",
link: "text-[#049B38] hover:text-[#038626]",
button: "bg-[#07C05F] shadow-[0_1px_3px_rgba(7,192,95,0.2)] hover:bg-[#08DD6E]",
};
}
if (theme === "lightblue") {
return {
dot: "bg-[#0062D9]",
badge: "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]",
focus: "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20",
text: "text-[#0062D9]",
hoverText: "group-hover:text-[#0062D9]",
link: "text-[#0062D9] hover:text-[#0056C6]",
button: "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]",
};
}
return {
dot: "bg-[#CF1322]",
badge: "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]",
focus: "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20",
text: "text-[#CF1322]",
hoverText: "group-hover:text-[#CF1322]",
link: "text-[#CF1322] hover:text-[#A8071A]",
button: "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]",
};
}
export default function LightAppListPage() { export default function LightAppListPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const { theme } = useTheme(); const { theme } = useTheme();
const isLightBlue = theme === 'lightblue'; const lightAccent = getLightAccent(theme);
const { data: apps = [], isLoading } = useLightApps({ const { data: apps = [], isLoading } = useLightApps({
appType: "window_open", appType: "window_open",
@@ -118,13 +152,13 @@ export default function LightAppListPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="relative flex size-3"> <span className="relative flex size-3">
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} /> <span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", lightAccent.dot, "dark:bg-[#1677FF]")} />
<span className={cn("relative inline-flex size-3 rounded-full", isLightBlue ? "bg-[#0062D9]" : "bg-[#CF1322]", "dark:bg-[#1677FF]")} /> <span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
</span> </span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]"> <h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表 应用列表
</h1> </h1>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", isLightBlue ? "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]" : "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]")}> <span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
导航工作台 导航工作台
</span> </span>
</div> </div>
@@ -142,7 +176,7 @@ export default function LightAppListPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="键入应用名称、目标地址、或描述,实时过滤检索…" placeholder="键入应用名称、目标地址、或描述,实时过滤检索…"
className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25", isLightBlue ? "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20" : "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20")} className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25", lightAccent.focus)}
/> />
{search && ( {search && (
<button <button
@@ -161,13 +195,13 @@ export default function LightAppListPage() {
<div className="-mt-2 flex items-center justify-between"> <div className="-mt-2 flex items-center justify-between">
<div className="text-xs font-medium text-zinc-500 dark:text-[#6E88B0]"> <div className="text-xs font-medium text-zinc-500 dark:text-[#6E88B0]">
检索到最终结果:{" "} 检索到最终结果:{" "}
<span className={cn("font-bold dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}>{apps.length}</span> 个关联应用 <span className={cn("font-bold dark:text-[#4096FF]", lightAccent.text)}>{apps.length}</span> 个关联应用
</div> </div>
{search && ( {search && (
<button <button
type="button" type="button"
onClick={() => setSearch("")} onClick={() => setSearch("")}
className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", isLightBlue ? "text-[#0062D9] hover:text-[#0056C6]" : "text-[#CF1322] hover:text-[#A8071A]")} className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", lightAccent.link)}
> >
重置过滤条件 重置过滤条件
</button> </button>
@@ -239,7 +273,7 @@ function AppCard({
onOpen: () => void; onOpen: () => void;
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
const isLightBlue = theme === 'lightblue'; const lightAccent = getLightAccent(theme);
const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)]; const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)];
const cardTheme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)]; const cardTheme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)];
@@ -256,7 +290,7 @@ function AppCard({
</div> </div>
</div> </div>
<h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", isLightBlue ? "group-hover:text-[#0062D9]" : "group-hover:text-[#CF1322]")}> <h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", lightAccent.hoverText)}>
{app.name} {app.name}
</h4> </h4>
@@ -270,7 +304,7 @@ function AppCard({
<div className="mb-4 rounded-[5px] bg-zinc-50/70 p-3 dark:bg-[#0C1836]"> <div className="mb-4 rounded-[5px] bg-zinc-50/70 p-3 dark:bg-[#0C1836]">
<div className="mb-1 flex items-center justify-between text-[10px] font-semibold text-zinc-400 dark:text-[#6E88B0]"> <div className="mb-1 flex items-center justify-between text-[10px] font-semibold text-zinc-400 dark:text-[#6E88B0]">
<span>拼接后目标路由终端:</span> <span>拼接后目标路由终端:</span>
<span className={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", isLightBlue ? "text-[#0062D9]" : "text-[#CF1322]")}> <span className={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", lightAccent.text)}>
自动拼接已激活参 自动拼接已激活参
</span> </span>
</div> </div>
@@ -312,7 +346,7 @@ function AppCard({
<button <button
type="button" type="button"
onClick={onOpen} onClick={onOpen}
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]", isLightBlue ? "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]" : "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]")} className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]", lightAccent.button)}
> >
<span>进入应用</span> <span>进入应用</span>
<ArrowUpRight className="size-3.5" /> <ArrowUpRight className="size-3.5" />
@@ -93,7 +93,7 @@ const newParam = (): LightAppRouteParam => ({
paramType: "login", paramType: "login",
source: "access_token", source: "access_token",
customValue: "", customValue: "",
themeValue: "red=red;blue=blue;dark-blue=dark-blue", themeValue: "red=red;green=green;blue=blue;dark-blue=dark-blue",
}); });
function formFromApp(app: LightApp): FormState { function formFromApp(app: LightApp): FormState {
@@ -10,6 +10,7 @@ export default function LightAppNoteQAPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const isLightBlue = theme === 'lightblue'; const isLightBlue = theme === 'lightblue';
const isLightGreen = theme === "lightgreen";
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const handleSubmit = () => { const handleSubmit = () => {
@@ -112,7 +113,9 @@ export default function LightAppNoteQAPage() {
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors", "flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark isDark
? "bg-sky-500 text-white hover:bg-sky-400" ? "bg-sky-500 text-white hover:bg-sky-400"
: isLightBlue : isLightGreen
? "bg-[#07C05F] text-white hover:bg-[#08DD6E]"
: isLightBlue
? "bg-[#0062D9] text-white hover:bg-[#0079FE]" ? "bg-[#0062D9] text-white hover:bg-[#0079FE]"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]", : "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)} )}
@@ -4,7 +4,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import { getNotebookApiBaseURL } from '@/core/config'; import { getNotebookApiBaseURL } from '@/core/config';
import { useTheme, type ThemeType } from '@/contexts/ThemeContext'; import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue']; const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue', 'lightgreen'];
interface NotebookTokenData { interface NotebookTokenData {
doc_ids: number[]; doc_ids: number[];
+15 -3
View File
@@ -16,6 +16,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu"; import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
import { useLayoutMode } from "@/core/system-settings/hooks"; import { useLayoutMode } from "@/core/system-settings/hooks";
import { env } from "@/env";
import WorkspaceRoutes from "./WorkspaceRoutes"; import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes"; import StrategyRoutes from "./StrategyRoutes";
@@ -27,6 +28,7 @@ import { RoundtableJobNotifier } from "@/roundtable-planning/components/Roundtab
function PageLayout() { function PageLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const forceCollapsedSidebar = env.FORCE_COLLAPSED_SIDEBAR;
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []); const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const location = useLocation(); const location = useLocation();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -42,7 +44,13 @@ function PageLayout() {
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。 // 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode(); const [layoutMode] = useLayoutMode();
const chromeByPreference = const chromeByPreference =
layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled; forceCollapsedSidebar
? false
: layoutMode === "full"
? true
: layoutMode === "compact"
? false
: sidebarEnabled;
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏, // iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。 // 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
const embedActive = useMemo( const embedActive = useMemo(
@@ -60,9 +68,12 @@ function PageLayout() {
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false // 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome** // (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。 // (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent"); const forceChromeForDashboardAgent =
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
const isWorkspacePage = location.pathname.startsWith("/page/workspace");
const pageSidebarAllowed = const pageSidebarAllowed =
(forceChromeForDashboardAgent || chromeByPreference) && (forceChromeForDashboardAgent || chromeByPreference) &&
!isWorkspacePage &&
!isChatsIframeWorkspacePath(location.pathname) && !isChatsIframeWorkspacePath(location.pathname) &&
!embedActive && !embedActive &&
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。 // `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
@@ -114,8 +125,9 @@ function PageLayout() {
pageSidebarCollapsed={collapsed} pageSidebarCollapsed={collapsed}
pageSidebarHidden={!pageSidebarAllowed} pageSidebarHidden={!pageSidebarAllowed}
headerHidden={!pageSidebarAllowed} headerHidden={!pageSidebarAllowed}
compactWorkspaceSidebar={pageSidebarAllowed} compactWorkspaceSidebar={pageSidebarAllowed || isWorkspacePage}
pageSidebarDark={isDark} pageSidebarDark={isDark}
forceWorkspaceSidebarCollapsed={forceCollapsedSidebar}
/> />
} }
/> />
+20 -38
View File
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth'; import { getStoredAuth } from '@/core/auth';
import { env } from '@/env';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input'; import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -11,7 +12,6 @@ import {
ThemeSwitcher, ThemeSwitcher,
} from '@/components/workspace/appearance-controls'; } from '@/components/workspace/appearance-controls';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar'; import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
import { CommandPalette } from '@/components/workspace/command-palette'; import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context'; import { SubtasksProvider } from '@/core/tasks/context';
@@ -79,29 +79,9 @@ 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 (
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
<UserIdentityBadge />
</div>
);
}
function CompactAppearanceActions() { function CompactAppearanceActions() {
if (env.FORCE_COLLAPSED_SIDEBAR) return null;
return ( return (
<> <>
<CompactShortcutButtons tone="content" /> <CompactShortcutButtons tone="content" />
@@ -122,6 +102,7 @@ export function WorkspaceLayout({
wrapperOverflowHidden = false, wrapperOverflowHidden = false,
insetOverflowHidden = false, insetOverflowHidden = false,
pageSidebarDark = false, pageSidebarDark = false,
forceWorkspaceSidebarCollapsed = false,
}: { }: {
children: ReactNode; children: ReactNode;
pageSidebarCollapsed: boolean; pageSidebarCollapsed: boolean;
@@ -134,14 +115,10 @@ export function WorkspaceLayout({
wrapperOverflowHidden?: boolean; wrapperOverflowHidden?: boolean;
insetOverflowHidden?: boolean; insetOverflowHidden?: boolean;
pageSidebarDark?: boolean; pageSidebarDark?: boolean;
forceWorkspaceSidebarCollapsed?: boolean;
}) { }) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页 // 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。 // (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
const showCompactTopActions =
headerHidden && shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden const pageSidebarWidth = pageSidebarHidden
? '0rem' ? '0rem'
: pageSidebarDark : pageSidebarDark
@@ -164,28 +141,29 @@ export function WorkspaceLayout({
{ {
'--page-sidebar-width': pageSidebarWidth, '--page-sidebar-width': pageSidebarWidth,
'--header-height': headerHidden ? '0rem' : '4rem', '--header-height': headerHidden ? '0rem' : '4rem',
// Shared by red, blue, and dark workbench themes. Keep this in sync
// with the Sidebar primitive instead of giving one route a wider rail.
'--sidebar-width': '14rem',
} as CSSProperties } as CSSProperties
} }
key={String(compactWorkspaceSidebar)} key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={ defaultOpen={
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar) forceWorkspaceSidebarCollapsed ? false : (defaultHistorySidebarOpen ?? true)
} }
open={forceWorkspaceSidebarCollapsed ? false : undefined}
onOpenChange={forceWorkspaceSidebarCollapsed ? () => undefined : undefined}
> >
{!workspaceSidebarHidden && ( {!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} /> <WorkspaceSidebar
compactChrome={compactWorkspaceSidebar}
forceCollapsed={forceWorkspaceSidebarCollapsed}
/>
)} )}
<SidebarInset <SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')} className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
> >
{showCompactTopActions ? ( <div className="min-h-0 flex-1">{children}</div>
<>
<CompactWorkspaceTopActions />
<div className="min-h-0 flex-1">{children}</div>
</>
) : (
children
)}
</SidebarInset> </SidebarInset>
<CommandPalette /> <CommandPalette />
<Toaster position="top-center" /> <Toaster position="top-center" />
@@ -227,6 +205,7 @@ function NewChatEntryRoute({
headerHidden: boolean; headerHidden: boolean;
compactWorkspaceSidebar: boolean; compactWorkspaceSidebar: boolean;
pageSidebarDark: boolean; pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
}; };
}) { }) {
if (isInIframe()) { if (isInIframe()) {
@@ -248,12 +227,14 @@ export default function WorkspaceRoutes({
headerHidden = false, headerHidden = false,
compactWorkspaceSidebar = false, compactWorkspaceSidebar = false,
pageSidebarDark = false, pageSidebarDark = false,
forceWorkspaceSidebarCollapsed = false,
}: { }: {
pageSidebarCollapsed?: boolean; pageSidebarCollapsed?: boolean;
pageSidebarHidden?: boolean; pageSidebarHidden?: boolean;
headerHidden?: boolean; headerHidden?: boolean;
compactWorkspaceSidebar?: boolean; compactWorkspaceSidebar?: boolean;
pageSidebarDark?: boolean; pageSidebarDark?: boolean;
forceWorkspaceSidebarCollapsed?: boolean;
}) { }) {
const layoutProps = { const layoutProps = {
pageSidebarCollapsed, pageSidebarCollapsed,
@@ -261,6 +242,7 @@ export default function WorkspaceRoutes({
headerHidden, headerHidden,
compactWorkspaceSidebar, compactWorkspaceSidebar,
pageSidebarDark, pageSidebarDark,
forceWorkspaceSidebarCollapsed,
}; };
return ( return (
@@ -679,6 +679,58 @@
background-color: #0056C6; background-color: #0056C6;
} }
/* ── WeKnora 绿色主题覆盖([data-theme="lightgreen"])──────────────── */
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save,
[data-theme="lightgreen"] .light-app-management-page .lam-switch-on {
background-color: #07C05F;
}
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save {
box-shadow: 0 2px 6px rgba(7, 192, 95, 0.2);
}
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn:hover,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save:hover {
background-color: #08DD6E;
}
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn:active,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save:active {
background-color: #049B38;
}
[data-theme="lightgreen"] .light-app-management-page .lam-search-input:focus {
border-color: rgba(7, 192, 95, 0.4);
box-shadow: 0 0 0 4px rgba(7, 192, 95, 0.1);
}
[data-theme="lightgreen"] .light-app-management-page .lam-view-toggle-btn.is-active,
[data-theme="lightgreen"] .light-app-management-page .lam-cat-toggle-btn.is-active,
[data-theme="lightgreen"] .light-app-management-page .lam-count strong,
[data-theme="lightgreen"] .light-app-management-page .lam-table tr:hover .lam-row-name,
[data-theme="lightgreen"] .light-app-management-page .lam-param-popover-close,
[data-theme="lightgreen"] .light-app-management-page .lam-param-key,
[data-theme="lightgreen"] .light-app-management-page .lam-card:hover .lam-card-title,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-close:hover,
[data-theme="lightgreen"] .light-app-management-page .lam-add-param-btn:hover {
color: #049B38;
}
[data-theme="lightgreen"] .light-app-management-page .lam-add-param-btn:hover {
border-color: #07C05F;
}
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-icon {
background-color: #E9F8EC;
color: #049B38;
}
[data-theme="lightgreen"] .light-app-management-page .lam-input:focus,
[data-theme="lightgreen"] .light-app-management-page .lam-dialog input[type="text"]:focus,
[data-theme="lightgreen"] .light-app-management-page .t-select.t-is-focused .t-input,
[data-theme="lightgreen"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
border-color: #07C05F;
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.16);
}
[data-theme="lightgreen"] .light-app-management-page .t-select:hover .t-input,
[data-theme="lightgreen"] .light-app-management-page .t-select:hover .t-input__wrap {
border-color: rgba(7, 192, 95, 0.5);
}
/* ── iframe 菜单排序:拖拽行 + 拖拽手柄 ───────────────────────────────── */ /* ── iframe 菜单排序:拖拽行 + 拖拽手柄 ───────────────────────────────── */
.light-app-management-page .lam-reorder-row { .light-app-management-page .lam-reorder-row {
cursor: grab; cursor: grab;
@@ -7,6 +7,11 @@
background-color: #F9FCFF; background-color: #F9FCFF;
} }
[data-theme="lightgreen"] .light-app-management-page,
[data-theme="lightgreen"] .light-app-list-page {
background-color: #F9FDF9;
}
.dark .light-app-management-page, .dark .light-app-management-page,
.dark .light-app-list-page { .dark .light-app-list-page {
background-color: var(--color-bg-page); background-color: var(--color-bg-page);
@@ -39,14 +39,15 @@ export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
]; ];
/** /**
* 我方主题色键。当前启用 red(浅色) / dark-blue(深色);blue 为后续将新增的主题。 * 我方主题色键:red(红白)、green(WeKnora 绿色)、blue(浅蓝)、dark-blue(深蓝)。
* 主题参数的映射以这些键为左侧 key(如 `dark-blue=darkBlue`)。 * 主题参数的映射以这些键为左侧 key(如 `dark-blue=darkBlue`)。
*/ */
export type AppThemeColor = "red" | "blue" | "dark-blue"; export type AppThemeColor = "red" | "green" | "blue" | "dark-blue";
/** 我方全部主题色(「应用管理」主题参数逐项配置目标值时遍历;dot 为配色小圆点)。 */ /** 我方全部主题色(「应用管理」主题参数逐项配置目标值时遍历;dot 为配色小圆点)。 */
export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string }[] = [ export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string }[] = [
{ key: "red", label: "红色", dot: "#F5222D" }, { key: "red", label: "红色", dot: "#F5222D" },
{ key: "green", label: "绿色", dot: "#07C05F" },
{ key: "blue", label: "蓝色", dot: "#1677FF" }, { key: "blue", label: "蓝色", dot: "#1677FF" },
{ key: "dark-blue", label: "深蓝色", dot: "#002B5C" }, { key: "dark-blue", label: "深蓝色", dot: "#002B5C" },
]; ];
@@ -57,18 +58,20 @@ export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string
*/ */
const THEME_KEY_ALIASES: Record<AppThemeColor, string[]> = { const THEME_KEY_ALIASES: Record<AppThemeColor, string[]> = {
red: ["red", "light"], red: ["red", "light"],
green: ["green", "lightgreen", "light-green"],
blue: ["blue"], blue: ["blue"],
"dark-blue": ["dark-blue", "darkblue", "dark"], "dark-blue": ["dark-blue", "darkblue", "dark"],
}; };
/** /**
* 读取我方当前主题色,以 ThemeContext / next-themes 写在 <html> 上的 class 为准 * 读取我方当前主题色,以 ThemeContext / next-themes 写在 <html> 上的 class 为准
* (含嵌入场景的强制主题)。深色=dark-blue,浅蓝=blue,其余(浅色/红白)=red。 * (含嵌入场景的强制主题)。深色=dark-blue,绿色=green,浅蓝=blue,其余(浅色/红白)=red。
*/ */
export function getCurrentTheme(): AppThemeColor { export function getCurrentTheme(): AppThemeColor {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
const classList = document.documentElement.classList; const classList = document.documentElement.classList;
if (classList.contains("dark")) return "dark-blue"; if (classList.contains("dark")) return "dark-blue";
if (classList.contains("lightgreen")) return "green";
if (classList.contains("lightblue")) return "blue"; if (classList.contains("lightblue")) return "blue";
} }
return "red"; return "red";
+123 -3
View File
@@ -407,6 +407,65 @@
--legacy-workspace-brand-rgb: 0 98 217; --legacy-workspace-brand-rgb: 0 98 217;
} }
/* WeKnora 绿色主题:沿用 WeKnora 的主色 #07C05F、浅底 #E9F8EC 与深强调色 #049B38。
仅覆盖颜色 token,圆角和间距保持 DeerFlow 原有布局。 */
.lightgreen {
--workspace-brand: #07c05f;
--workspace-brand-foreground: #ffffff;
--brand-200: #e9f8ec;
--brand-900: #07c05f;
--brand-950: #049b38;
--background: #ffffff;
--foreground: #203329;
--card: #ffffff;
--card-foreground: #203329;
--popover: #ffffff;
--popover-foreground: #203329;
--primary: #07c05f;
--primary-foreground: #ffffff;
--secondary: #f1fbf4;
--secondary-foreground: #049b38;
--muted: #f3f8f4;
--muted-foreground: #64736a;
--accent: #e9f8ec;
--accent-foreground: #038626;
--destructive: #f5222d;
--border: #d8eadc;
--input: #cfe5d4;
--ring: rgb(7 192 95 / 0.35);
--chart-1: #07c05f;
--chart-2: #028f72;
--chart-3: #4f6680;
--chart-4: #e8a72a;
--chart-5: #db8d16;
--sidebar: #f9fdf9;
--sidebar-foreground: #203329;
--sidebar-primary: #07c05f;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e9f8ec;
--sidebar-accent-foreground: #038626;
--sidebar-border: #e3f0e5;
--sidebar-ring: rgb(7 192 95 / 0.4);
--legacy-accent-rgb: 233 248 236;
--legacy-background-rgb: 255 255 255;
--legacy-border-rgb: 216 234 220;
--legacy-brand-200-rgb: 233 248 236;
--legacy-card-rgb: 255 255 255;
--legacy-destructive-rgb: 245 34 45;
--legacy-foreground-rgb: 32 51 41;
--legacy-input-rgb: 207 229 212;
--legacy-muted-rgb: 243 248 244;
--legacy-muted-foreground-rgb: 100 115 106;
--legacy-primary-rgb: 7 192 95;
--legacy-primary-foreground-rgb: 255 255 255;
--legacy-ring-rgb: 7 192 95;
--legacy-secondary-rgb: 241 251 244;
--legacy-sidebar-accent-rgb: 233 248 236;
--legacy-sidebar-border-rgb: 227 240 229;
--legacy-sidebar-foreground-rgb: 32 51 41;
--legacy-workspace-brand-rgb: 7 192 95;
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
@@ -1232,6 +1291,44 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #55a6fe 35%, transparent); background: color-mix(in srgb, #55a6fe 35%, transparent);
} }
/* ── WeKnora 绿色主题:覆盖未使用 token 的历史硬编码颜色 ─────────────── */
html.lightgreen .strategy-app-header {
background-color: #07c05f;
background-image: linear-gradient(105deg, #049b38 0%, #07c05f 48%, #08dd6e 100%);
}
html.lightgreen *::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgb(9 244 121 / 0.8), rgb(7 192 95 / 0.92))
padding-box;
}
html.lightgreen .workspace-panel {
border: 1px solid #d8eadc;
box-shadow:
0 32px 72px -46px rgb(26 112 68 / 0.3),
0 12px 28px -22px rgb(7 192 95 / 0.2);
}
html.lightgreen .workspace-user-bubble {
border: 1px solid rgb(255 255 255 / 0.14);
background: linear-gradient(135deg, #09f479 0%, #07c05f 58%, #049b38 100%);
box-shadow:
0 22px 40px -28px rgb(7 192 95 / 0.52),
0 10px 20px -16px rgb(4 155 56 / 0.38);
}
html.lightgreen .workspace-assistant-card {
border: 1px solid #dceee0;
}
html.lightgreen .workspace-assistant-card::before {
background: linear-gradient(180deg, rgb(9 244 121 / 0.68), rgb(7 192 95 / 0.12));
}
html.lightgreen .tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #07c05f 35%, transparent);
}
/* ── 来源上传弹窗:A系统基因库的分段单选条(custom-radio) ─────────────── */ /* ── 来源上传弹窗:A系统基因库的分段单选条(custom-radio) ─────────────── */
/* 外层容器,模拟 nz-radio-group */ /* 外层容器,模拟 nz-radio-group */
.custom-radio-group { .custom-radio-group {
@@ -1290,9 +1387,9 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
* agent surfaces on classic rgba()/hex colours instead. * agent surfaces on classic rgba()/hex colours instead.
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
.agent-avatar { .agent-avatar {
background-color: #feeded; background-color: #fff1f0;
color: #cf1322; color: #be2323;
box-shadow: inset 0 0 0 1px rgba(190, 35, 35, 0.15); box-shadow: inset 0 0 0 1px rgba(190, 35, 35, 0.16);
} }
.dark .agent-avatar { .dark .agent-avatar {
@@ -1307,6 +1404,12 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
box-shadow: inset 0 0 0 1px rgba(0, 98, 217, 0.16); box-shadow: inset 0 0 0 1px rgba(0, 98, 217, 0.16);
} }
.lightgreen .agent-avatar {
background-color: #e9f8ec;
color: #049b38;
box-shadow: inset 0 0 0 1px rgb(7 192 95 / 0.16);
}
.agent-sidebar-card--active { .agent-sidebar-card--active {
background-color: #feeded; background-color: #feeded;
border-color: rgba(190, 35, 35, 0.5); border-color: rgba(190, 35, 35, 0.5);
@@ -1322,6 +1425,11 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
border-color: rgba(0, 98, 217, 0.5); border-color: rgba(0, 98, 217, 0.5);
} }
.lightgreen .agent-sidebar-card--active {
background-color: #e9f8ec;
border-color: rgb(7 192 95 / 0.5);
}
@supports not (color: oklch(1 0 0)) { @supports not (color: oklch(1 0 0)) {
.bg-primary\/5 { background-color: rgba(190, 35, 35, 0.05); } .bg-primary\/5 { background-color: rgba(190, 35, 35, 0.05); }
.bg-primary\/8 { background-color: rgba(190, 35, 35, 0.08); } .bg-primary\/8 { background-color: rgba(190, 35, 35, 0.08); }
@@ -1372,4 +1480,16 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
.lightblue .bg-primary\/60 { background-color: rgba(0, 98, 217, 0.60); } .lightblue .bg-primary\/60 { background-color: rgba(0, 98, 217, 0.60); }
.lightblue .bg-primary\/70 { background-color: rgba(0, 98, 217, 0.70); } .lightblue .bg-primary\/70 { background-color: rgba(0, 98, 217, 0.70); }
.lightblue .bg-primary\/90 { background-color: rgba(0, 98, 217, 0.90); } .lightblue .bg-primary\/90 { background-color: rgba(0, 98, 217, 0.90); }
.lightgreen .bg-primary\/5 { background-color: rgba(7, 192, 95, 0.05); }
.lightgreen .bg-primary\/8 { background-color: rgba(7, 192, 95, 0.08); }
.lightgreen .bg-primary\/10 { background-color: rgba(7, 192, 95, 0.10); }
.lightgreen .bg-primary\/14 { background-color: rgba(7, 192, 95, 0.14); }
.lightgreen .bg-primary\/15 { background-color: rgba(7, 192, 95, 0.15); }
.lightgreen .bg-primary\/20 { background-color: rgba(7, 192, 95, 0.20); }
.lightgreen .bg-primary\/25 { background-color: rgba(7, 192, 95, 0.25); }
.lightgreen .bg-primary\/30 { background-color: rgba(7, 192, 95, 0.30); }
.lightgreen .bg-primary\/60 { background-color: rgba(7, 192, 95, 0.60); }
.lightgreen .bg-primary\/70 { background-color: rgba(7, 192, 95, 0.70); }
.lightgreen .bg-primary\/90 { background-color: rgba(7, 192, 95, 0.90); }
} }
+1 -1
View File
@@ -846,7 +846,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
### WeKnora Retrieval Skill ### 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`) ### Embedded Client (`packages/harness/deerflow/client.py`)
+1 -1
View File
@@ -78,7 +78,7 @@ Per-thread isolated execution with virtual path translation:
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories - **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory - **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 - **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 - **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) - **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
@@ -536,7 +536,9 @@ async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Req
class AppearanceResponse(BaseModel): class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults.""" """Effective system-wide appearance defaults."""
default_theme: Literal["light", "dark", "lightblue"] = Field(..., description="System default theme — light, dark or lightblue") default_theme: Literal["light", "dark", "lightblue", "lightgreen"] = Field(
..., description="System default theme — light, dark, lightblue or lightgreen"
)
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list") default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list") default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)") default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
@@ -544,7 +546,7 @@ class AppearanceResponse(BaseModel):
class AppearanceUpdateRequest(BaseModel): class AppearanceUpdateRequest(BaseModel):
default_theme: Literal["light", "dark", "lightblue"] | None = Field(default=None) default_theme: Literal["light", "dark", "lightblue", "lightgreen"] | None = Field(default=None)
default_skill_view: Literal["card", "list"] | None = Field(default=None) default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None) default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None) default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
@@ -61,7 +61,7 @@ def _default_shortcut_route_params() -> list[AppearanceRouteParam]:
paramType="login", paramType="login",
source="access_token", source="access_token",
customValue="", customValue="",
themeValue="red=red;blue=blue;dark-blue=dark-blue", themeValue="red=red;green=green;blue=blue;dark-blue=dark-blue",
) )
] ]
@@ -102,9 +102,9 @@ class AppearanceSettings(BaseModel):
toggle, and any future first-time loads. toggle, and any future first-time loads.
""" """
default_theme: Literal["light", "dark", "lightblue"] = Field( default_theme: Literal["light", "dark", "lightblue", "lightgreen"] = Field(
default="light", default="light",
description="Theme applied for users who haven't picked their own (light / dark / lightblue)", description="Theme applied for users who haven't picked their own (light / dark / lightblue / lightgreen)",
) )
default_skill_view: Literal["card", "list"] = Field( default_skill_view: Literal["card", "list"] = Field(
default="list", default="list",