Merge remote-tracking branch 'origin/master' into xuhd

# Conflicts:
#	frontend-web/.env.production
#	frontend-web/.env.sit
#	frontend-web/package.json
#	offline-backend-20260512/backend/config.yaml
This commit is contained in:
xuhd committed 2026-06-11 14:19:01 +08:00
commit 1668c1da87
177 files changed
+21030 -2738

No files matched your search

@@ -8,12 +8,14 @@ import {
LockIcon,
MessageSquareIcon,
PinIcon,
SearchIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
TagsIcon,
Trash2Icon,
UserIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import {
@@ -73,6 +75,9 @@ interface AgentCardProps {
/** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list";
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
* owner user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
}
interface BadgeItem {
@@ -184,7 +189,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
);
}
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
@@ -207,6 +212,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
const [skillSearch, setSkillSearch] = useState("");
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
@@ -244,6 +250,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
setSkillSearch("");
}, [
agent.description,
agent.is_private,
@@ -436,7 +443,24 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
});
}
const enabledSkills = skills.filter((skill) => skill.enabled);
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Search over English name, Chinese name (name_zh) and description, so both
// 中英文 work. Already-selected skills stay searchable too (selection is
// independent of this filter).
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
@@ -497,6 +521,47 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</>
);
// 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。点击可按发布者筛选。
const publisherLabel = agent.owner_username
? desensitize(agent.owner_username)
: "";
const canFilterByPublisher =
!isBuiltin && !!agent.owner_username && !!agent.user_id && !!onPublisherClick;
const publisherNode =
!isBuiltin && agent.owner_username ? (
<button
type="button"
disabled={!canFilterByPublisher}
onClick={(e) => {
if (!canFilterByPublisher) return;
e.stopPropagation();
onPublisherClick!({ id: agent.user_id!, label: publisherLabel });
}}
className={cn(
"text-muted-foreground flex max-w-full items-center gap-1 text-sm font-medium",
canFilterByPublisher &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
canFilterByPublisher
? `点击筛选发布者:${publisherLabel}`
: `发布者:${publisherLabel}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{publisherLabel}</span>
</button>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>();
for (const s of skills) {
if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim());
}
return map;
}, [skills]);
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
@@ -519,7 +584,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
setDetailSkillName(skill);
}}
>
{desensitize(skill)}
{skillNameZhMap.get(skill) ?? desensitize(skill)}
</Badge>
),
})),
@@ -585,15 +650,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
{isOwned && (
<Button
size="icon"
@@ -702,6 +767,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</CardDescription>
)}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
@@ -747,6 +813,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</p>
)}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
@@ -792,6 +859,17 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</div>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能(支持中英文名称、描述)…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
@@ -799,9 +877,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<p className="text-muted-foreground text-sm">
当前没有可用技能
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => {
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
@@ -816,7 +896,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
title={desensitize(skill.description ?? "")}
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">{desensitize(skill.name)}</span>
<span className="truncate">
{skill.name_zh?.trim()
? skill.name_zh
: desensitize(skill.name)}
</span>
</Button>
<Button
type="button"
@@ -934,13 +1018,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<SkillDetailDialog
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -36,6 +36,11 @@ export function AgentGallery() {
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
@@ -48,7 +53,7 @@ export function AgentGallery() {
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort]);
}, [search, tagKey, sort, publisher?.id]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -69,6 +74,7 @@ export function AgentGallery() {
agents: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
} = useAgentsPaginated({
scope: mainScope,
search,
@@ -77,12 +83,14 @@ export function AgentGallery() {
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
owner: publisher?.id,
});
const {
agents: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
isFetching: isPrivateFetching,
error: privateError,
} = usePrivateAgentsPaginated(privatePassword, {
search,
@@ -91,6 +99,7 @@ export function AgentGallery() {
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
owner: publisher?.id,
});
// If the server reports the stored password no longer works (likely the
@@ -103,6 +112,12 @@ export function AgentGallery() {
}
}, [privateError, privatePassword]);
// The private tab is only "unlocked" once the feature is usable and a
// password has been verified. Until then we show the gate (or the lock
// prompt) and hide the keyword/tag search bar — there is no list to filter.
const privateUnlocked = privateFeatureUsable && !!privatePassword;
const showSearchBar = activeTab !== "private" || privateUnlocked;
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -127,17 +142,29 @@ export function AgentGallery() {
setPrivatePassword(null);
};
const handlePublisherClick = (p: { id: string; label: string }) =>
setPublisher(p);
const renderGrid = (list: Agent[]) =>
viewMode === "list" ? (
<div className="flex flex-col gap-2">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" />
<AgentCard
key={agent.id}
agent={agent}
layout="list"
onPublisherClick={handlePublisherClick}
/>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
<AgentCard
key={agent.id}
agent={agent}
onPublisherClick={handlePublisherClick}
/>
))}
</div>
);
@@ -296,9 +323,16 @@ export function AgentGallery() {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={(v) =>
setActiveTab(v as "all" | "mine" | "private")
}
onValueChange={(v) => {
setActiveTab(v as "all" | "mine" | "private");
// Each tab starts from a clean slate: switching tabs clears the
// keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below.
setSearch("");
setTagIds([]);
setSort("time_desc");
setPublisher(null);
}}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex flex-col gap-3">
@@ -331,31 +365,53 @@ export function AgentGallery() {
)}
</div>
</div>
<TagSearchBar
targetType="agent"
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>
}
/>
{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 && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
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"
title="点击清除发布者筛选"
>
{publisher.label}
<XIcon className="size-3.5" />
</button>
</div>
)}
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
@@ -0,0 +1,44 @@
"use client";
import { Database } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreadSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { Tooltip } from "./tooltip";
/**
* Per-conversation toggle for knowledge-base RAG injection (phase 2).
*
* Writes ``context.knowledge_rag_enabled`` into the thread settings, which the
* run forwards as ``runtime.context`` so ``KnowledgeRagMiddleware`` injects
* recalled knowledge before each model call for this conversation only.
*/
export function KnowledgeRagToggle({ threadId }: { threadId: string }) {
const [settings, setSettings] = useThreadSettings(threadId);
const enabled = settings.context.knowledge_rag_enabled === true;
const toggle = () => {
setSettings("context", {
...settings.context,
knowledge_rag_enabled: !enabled,
});
};
return (
<Tooltip content={enabled ? "知识库召回:已开启" : "知识库召回:已关闭"}>
<Button
variant="ghost"
onClick={toggle}
className={cn(
"text-muted-foreground hover:text-foreground",
enabled && "text-primary hover:text-primary",
)}
>
<Database />
知识库
</Button>
</Tooltip>
);
}
@@ -0,0 +1,59 @@
"use client";
import { BookMarked } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import { ModelNameSelect } from "./model-name-select";
import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip";
export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
const { thread } = useThread();
const { ingestThread } = useKnowledgeActions();
// Optional model override for the extraction (null = configured default).
const [model, setModel] = useState<string | null>(null);
if (thread.messages.length === 0) {
return null;
}
// One click → fire-and-forget. The backend sediments the thread into the
// wiki asynchronously (title/tags auto-generated), so the click never blocks.
const handleSave = async () => {
try {
await ingestThread.mutateAsync({
threadId,
payload: { background: true, model_name: model },
});
toast.success("已加入知识库保存队列,正在后台整理…");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<div className="flex items-center gap-1.5">
<ModelNameSelect
value={model}
onChange={setModel}
disabled={ingestThread.isPending}
triggerClassName="h-8 w-[8.5rem]"
/>
<Tooltip content="保存到知识库(用所选大模型后台异步整理)">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => void handleSave()}
disabled={ingestThread.isPending}
>
<BookMarked />
{ingestThread.isPending ? "保存中…" : "保存"}
</Button>
</Tooltip>
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
/** Sentinel for "use the backend's default/configured model" (no override). */
const DEFAULT_VALUE = "__default__";
/**
* Compact dropdown to optionally pick an LLM model for a one-off action
* (skill-name generation, knowledge ingestion, …). The value is the model
* `name`; `null` means "use the configured default" (no override sent).
*/
export function ModelNameSelect({
value,
onChange,
disabled,
className,
triggerClassName,
defaultLabel = "默认模型",
}: {
value: string | null;
onChange: (modelName: string | null) => void;
disabled?: boolean;
className?: string;
triggerClassName?: string;
defaultLabel?: string;
}) {
const { models } = useModels();
return (
<Select
value={value ?? DEFAULT_VALUE}
onValueChange={(v) => onChange(v === DEFAULT_VALUE ? null : v)}
disabled={disabled}
>
<SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
>
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value={DEFAULT_VALUE}>{defaultLabel}</SelectItem>
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -7,7 +7,10 @@ import {
FileText,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Share2,
Star,
Trash2,
} from "lucide-react";
import Link from "next/link";
@@ -54,9 +57,17 @@ import {
useDeleteThread,
useRenameThread,
useThreads,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import {
isThreadFavorite,
isThreadPinned,
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
@@ -82,6 +93,8 @@ export function RecentChatList() {
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
// Rename dialog state
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
@@ -160,11 +173,13 @@ export function RecentChatList() {
[t],
);
const visibleThreads = threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
const visibleThreads = sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
),
);
if (visibleThreads.length === 0) {
@@ -195,6 +210,8 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
@@ -210,10 +227,15 @@ export function RecentChatList() {
}
>
<Link
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{desensitize(titleOfThread(thread, t.pages.newChat))}
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -243,6 +265,36 @@ export function RecentChatList() {
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
togglePin(thread.thread_id, !pinned)
}
>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>{pinned ? t.common.unpin : t.common.pin}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleFavorite(thread.thread_id, !favorited)
}
>
<Star
className={
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground"
}
/>
<span>
{favorited
? t.common.unfavorite
: t.common.favorite}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
@@ -30,6 +30,10 @@ import {
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [
@@ -336,6 +340,17 @@ export function AppearanceSettingsPage() {
</div>
)}
</SettingsSection>
{isAdmin && (
<>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
</>
)}
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert, Briefcase, UserRound, Sparkles, Clock, History, Archive } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -458,12 +458,24 @@ const CTX_LABELS: Record<string, string> = {
topOfMind: "当前重点",
};
const CTX_ICONS: Record<string, React.ElementType> = {
workContext: Briefcase,
personalContext: UserRound,
topOfMind: Sparkles,
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const HISTORY_ICONS: Record<string, React.ElementType> = {
recentMonths: Clock,
earlierContext: History,
longTermBackground: Archive,
};
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
zh: {
preference: "偏好",
@@ -572,12 +584,15 @@ function MemoryV1View({
<Icon className="w-3.5 h-3.5" />
{categoryLabel}
</span>
<div className="flex flex-col items-end gap-1">
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
{Math.round(fact.confidence * 100)}%
<div
className="flex flex-col items-end gap-1"
title={`置信度:系统对这条记忆准确性的把握程度,${Math.round(fact.confidence * 100)}%`}
>
<span className="text-[11px] text-muted-foreground font-semibold tracking-wide">
置信度 {Math.round(fact.confidence * 100)}%
</span>
{/* Confidence Progress Bar */}
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
<div className="h-1 w-16 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
@@ -611,44 +626,77 @@ function MemoryV1View({
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4">
<TabsContent value="profile" className="mt-3 space-y-5">
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">用户画像</h3>
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<UserRound className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">用户画像</h3>
</div>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无画像信息,对话后系统会自动归纳。
</p>
) : (
<dl className="space-y-2">
{ctxEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ctxEntries.map(([key, value]) => {
const Icon = CTX_ICONS[key] ?? UserRound;
return (
<div
key={key}
className="group rounded-lg border border-border/50 bg-gradient-to-br from-card to-card/40 p-3.5 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:from-[#022248]/40 dark:to-[#022248]/10"
>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5 text-primary/80" />
{CTX_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)}
</div>
</section>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">历史摘要</h3>
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<History className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">历史摘要</h3>
</div>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无历史摘要。
</p>
) : (
<dl className="space-y-2">
{historyEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
<div className="space-y-3">
{historyEntries.map(([key, value]) => {
const Icon = HISTORY_ICONS[key] ?? History;
return (
<div
key={key}
className="relative rounded-lg border border-border/50 bg-card/60 p-3.5 pl-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:bg-[#022248]/30"
>
<span className="absolute inset-y-2 left-0 w-1 rounded-full bg-primary/30" />
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Icon className="h-3.5 w-3.5 text-primary/80" />
{HISTORY_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)}
</div>
</section>
</TabsContent>
</Tabs>
</div>
@@ -22,6 +22,8 @@ import {
Trash2Icon,
TrendingUpIcon,
Upload,
UserIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -55,6 +57,7 @@ import {
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -67,9 +70,12 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useDuplicateSkill,
useGenerateMissingSkillNameZh,
useGenerateSkillNameZh,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillFeatured,
useSetSkillNameZh,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
@@ -152,8 +158,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1);
// Publisher filter: click a skill's 发布者 to keep only that user's skills.
// {id} is owner_user_id; {label} the desensitized display name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
const [viewMode, setViewMode] = useViewMode("skill");
const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default).
const [fillModel, setFillModel] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
@@ -200,7 +214,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
};
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo(
() =>
publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills,
[scopedSkills, publisher],
);
const orderedSkills = useMemo(
() =>
filteredSkills
@@ -225,12 +246,34 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
}, [search, tagIds, sort, publisher?.id, filter]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
);
const handleFillNames = async () => {
try {
const result = await fillNamesMutation.mutateAsync(fillModel);
const okCount = result.generated.length;
const failCount = result.failed.length;
if (okCount === 0 && failCount === 0) {
toast.info("所有技能均已配置中文名称");
} else if (failCount === 0) {
toast.success(`已为 ${okCount} 个技能补充中文名称`);
} else if (result.model_error) {
// Failures came from the model itself — say so explicitly.
toast.warning(
`已补充 ${okCount} 个,${failCount} 个因大模型报错失败:${result.model_error}`,
);
} else {
toast.warning(`已补充 ${okCount} 个,${failCount} 个生成失败,可稍后重试或手动填写`);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleRescan = async () => {
try {
const result = await reconcileMutation.mutateAsync();
@@ -321,6 +364,37 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
? t.settings.skills.rescanSkillsRunning
: t.settings.skills.rescanSkills}
</Button>
{isAdmin && (
<>
<ModelNameSelect
value={fillModel}
onChange={setFillModel}
disabled={
fillNamesMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
triggerClassName="h-8 w-[9.5rem]"
/>
<Button
size="sm"
variant="outline"
className="gap-1.5 border-violet-300 bg-violet-50 font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleFillNames()}
disabled={
fillNamesMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
title="用所选大模型为所有尚未配置中文名称的技能自动生成中文名(仅管理员)"
>
{fillNamesMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
{fillNamesMutation.isPending ? "补充中…" : "一键补充中文名"}
</Button>
</>
)}
<Button
size="sm"
variant="outline"
@@ -362,6 +436,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</div>
}
/>
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
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"
title="点击清除发布者筛选"
>
{publisher.label}
<XIcon className="size-3.5" />
</button>
</div>
)}
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
@@ -384,6 +472,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
currentUserId={currentUserId}
isAdmin={isAdmin}
layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/>
))}
</div>
@@ -441,12 +530,16 @@ export function SkillRow({
currentUserId,
isAdmin,
layout = "row",
onPublisherClick,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card";
/** Click the 发布者 line to filter the list to that publisher. Receives the
* owner_user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
}) {
const isCard = layout === "card";
const { t } = useI18n();
@@ -549,8 +642,13 @@ export function SkillRow({
const badgeGroup = (
<div className="flex flex-wrap items-center gap-2">
<span className={cn(isCard && "truncate font-medium")}>
{desensitize(skill.name)}
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
</span>
{skill.name_zh?.trim() && (
<span className="text-muted-foreground truncate text-xs font-normal">
{desensitize(skill.name)}
</span>
)}
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
@@ -631,6 +729,33 @@ export function SkillRow({
const metaNodes = (
<>
{!isBuiltin && skill.owner_username && (
<button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(e) => {
if (!skill.owner_user_id || !onPublisherClick) return;
e.stopPropagation();
onPublisherClick({
id: skill.owner_user_id,
label: desensitize(skill.owner_username!),
});
}}
className={cn(
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
skill.owner_user_id && onPublisherClick &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
skill.owner_user_id && onPublisherClick
? `点击筛选发布者:${desensitize(skill.owner_username)}`
: `发布者:${desensitize(skill.owner_username)}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
@@ -649,15 +774,15 @@ export function SkillRow({
const actionButtons = (
<>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
@@ -842,13 +967,13 @@ export function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
<SkillDetailDialog
skillName={skill.name}
open={detailOpen}
@@ -892,8 +1017,16 @@ function InstallSkillDialog({
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
// After a successful install the dialog switches to a "set Chinese name" step.
// `installedName` holds the just-installed skill name (null = still on the upload step).
const [installedName, setInstalledName] = useState<string | null>(null);
const [nameZhDraft, setNameZhDraft] = useState("");
// Optional model override for the "AI generate" button (null = configured default).
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const reset = useCallback(() => {
setFile(null);
@@ -901,14 +1034,30 @@ function InstallSkillDialog({
setValidationError(null);
setIsDragging(false);
setTargetCategory("custom");
setInstalledName(null);
setNameZhDraft("");
setNameZhModel(null);
if (inputRef.current) inputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
if (validateMutation.isPending || installMutation.isPending) return;
if (
validateMutation.isPending ||
installMutation.isPending ||
setNameZhMutation.isPending ||
generateNameZhMutation.isPending
)
return;
reset();
onClose();
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
}, [
generateNameZhMutation.isPending,
installMutation.isPending,
onClose,
reset,
setNameZhMutation.isPending,
validateMutation.isPending,
]);
const acceptFile = useCallback(
async (selected: File) => {
@@ -964,15 +1113,47 @@ function InstallSkillDialog({
result.skill_name,
),
);
reset();
onClose();
// Stay open and advance to the optional "set Chinese name" step.
setInstalledName(result.skill_name);
setNameZhDraft("");
setNameZhModel(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, targetCategory],
);
const handleGenerateNameZh = useCallback(async () => {
if (!installedName) return;
try {
const result = await generateNameZhMutation.mutateAsync({
skillName: installedName,
modelName: nameZhModel,
});
setNameZhDraft(result.name_zh ?? "");
toast.success(dialog.nameZhGenerated);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [dialog.nameZhGenerated, generateNameZhMutation, installedName, nameZhModel]);
const handleSaveNameZh = useCallback(async () => {
if (!installedName) return;
const trimmed = nameZhDraft.trim();
try {
// Empty draft = keep whatever is stored (or none); just close.
if (trimmed) {
await setNameZhMutation.mutateAsync({ skillName: installedName, nameZh: trimmed });
toast.success(dialog.nameZhSaved);
}
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
@@ -984,8 +1165,70 @@ function InstallSkillDialog({
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{dialog.title}</DialogTitle>
<DialogTitle>{installedName ? dialog.nameZhStepTitle : dialog.title}</DialogTitle>
</DialogHeader>
{installedName && (
<>
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
{dialog.nameZhStepHint.replace("{name}", desensitize(installedName))}
</p>
<div className="flex items-center gap-2">
<Input
value={nameZhDraft}
onChange={(e) => setNameZhDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSaveNameZh();
}}
placeholder={dialog.nameZhPlaceholder}
className="h-9 flex-1"
autoFocus
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
/>
<ModelNameSelect
value={nameZhModel}
onChange={setNameZhModel}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
triggerClassName="h-9 w-[9rem] shrink-0"
/>
<Button
variant="outline"
className="h-9 shrink-0 gap-1 border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleGenerateNameZh()}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
title={dialog.nameZhGenerate}
>
{generateNameZhMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
{dialog.nameZhGenerate}
</Button>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
>
{dialog.nameZhSkip}
</Button>
<Button
onClick={() => void handleSaveNameZh()}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
>
{setNameZhMutation.isPending && <Loader2 className="size-4 animate-spin" />}
{t.common.save}
</Button>
</DialogFooter>
</>
)}
{!installedName && (
<>
<div className="flex flex-col gap-4">
<div
onDragOver={(e) => {
@@ -1148,6 +1391,8 @@ function InstallSkillDialog({
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
@@ -11,6 +11,7 @@ import {
PencilIcon,
SlidersHorizontalIcon,
SparklesIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -24,16 +25,20 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import {
useGenerateSkillNameZh,
useSetSkillDetail,
useSetSkillNameZh,
useSkill,
useSkillContent,
useSkillFiles,
@@ -98,9 +103,15 @@ export function SkillDetailDialog({
const activeName = open ? skillName : null;
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
const setDetail = useSetSkillDetail();
const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [editingNameZh, setEditingNameZh] = useState(false);
const [nameZhDraft, setNameZhDraft] = useState("");
// Optional model override for the "AI 生成" button (null = configured default).
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<
"detail" | "display" | "source" | "files" | null
>(null);
@@ -109,13 +120,20 @@ export function SkillDetailDialog({
const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
// Admins may browse any skill's files (the backend authorizes this); owners
// may browse their own custom skill. Edit-ability is decided separately by
// the backend's `filesData.editable` flag.
const canViewFiles = useMemo(() => {
if (!skill) return false;
return isOwner || getStoredAuth()?.system_role === "admin";
}, [skill, isOwner]);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open ? skillName : null);
const filesEnabled = open && isOwner && activeTab === "files";
const filesEnabled = open && canViewFiles && activeTab === "files";
const {
data: filesData,
isLoading: isFilesLoading,
@@ -124,11 +142,16 @@ export function SkillDetailDialog({
useEffect(() => {
setEditing(false);
setEditingNameZh(false);
setActiveTab(null);
setShowRawSource(false);
setOpenedFilePath(null);
}, [open, skillName]);
useEffect(() => {
setNameZhDraft(skill?.name_zh ?? "");
}, [skill?.name, skill?.name_zh]);
const skillBody = useMemo(() => {
return stripYamlFrontmatter(contentData?.content ?? "");
}, [contentData]);
@@ -152,6 +175,39 @@ export function SkillDetailDialog({
}
}
function startEditingNameZh() {
setNameZhDraft(skill?.name_zh ?? "");
setEditingNameZh(true);
}
async function handleSaveNameZh() {
if (!skill) return;
try {
await setNameZh.mutateAsync({
skillName: skill.name,
nameZh: nameZhDraft.trim(),
});
toast.success("中文名称已保存");
setEditingNameZh(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleGenerateNameZh() {
if (!skill) return;
try {
const result = await generateNameZh.mutateAsync({
skillName: skill.name,
modelName: nameZhModel,
});
setNameZhDraft(result.name_zh ?? "");
toast.success("已生成中文名称");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function copySource() {
await navigator.clipboard.writeText(contentData?.content ?? "");
toast.success("Copied SKILL.md content");
@@ -174,25 +230,105 @@ export function SkillDetailDialog({
<SparklesIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
{editingNameZh ? (
<div className="flex items-center gap-1.5">
<DialogTitle className="sr-only">
{desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
<Input
value={nameZhDraft}
onChange={(event) => setNameZhDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSaveNameZh();
if (event.key === "Escape") setEditingNameZh(false);
}}
placeholder={desensitize(skill?.name ?? skillName ?? "")}
className="h-8 max-w-[14rem] flex-1 text-sm"
autoFocus
disabled={generateNameZh.isPending || setNameZh.isPending}
/>
<ModelNameSelect
value={nameZhModel}
onChange={setNameZhModel}
disabled={generateNameZh.isPending || setNameZh.isPending}
triggerClassName="h-8 w-[8.5rem] shrink-0"
/>
<Button
size="sm"
variant="outline"
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
className="h-8 shrink-0 gap-1 border-violet-300 bg-violet-50 px-2.5 text-xs font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleGenerateNameZh()}
disabled={generateNameZh.isPending || setNameZh.isPending}
title="用所选大模型自动生成中文名"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
{generateNameZh.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
AI 生成
</Button>
<Button
size="sm"
className="h-8 shrink-0 px-3 text-xs"
onClick={() => void handleSaveNameZh()}
disabled={setNameZh.isPending || generateNameZh.isPending}
>
{setNameZh.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
{t.common.save}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-8 shrink-0"
onClick={() => setEditingNameZh(false)}
disabled={setNameZh.isPending}
title={t.common.cancel}
>
<XIcon className="size-4" />
</Button>
</div>
) : (
<div className="group flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{skill?.name_zh?.trim()
? skill.name_zh
: desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
{skill?.name_zh?.trim() && (
<span className="text-muted-foreground truncate font-mono text-xs">
{desensitize(skill?.name ?? skillName ?? "")}
</span>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-6 shrink-0 opacity-60 transition-opacity hover:opacity-100"
onClick={startEditingNameZh}
title={skill?.name_zh?.trim() ? "编辑中文名" : "设置中文名"}
>
<PencilIcon className="size-3" />
</Button>
)}
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
)}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description ? desensitize(skill.description) : "-"}
</DialogDescription>
@@ -227,7 +363,7 @@ export function SkillDetailDialog({
展示适配
</TabsTrigger>
)}
{isOwner && (
{canViewFiles && (
<TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle}
@@ -349,7 +485,7 @@ export function SkillDetailDialog({
</TabsContent>
)}
{isOwner && (
{canViewFiles && (
<TabsContent
value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
@@ -1,8 +1,9 @@
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, Loader2, SearchIcon } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags";
@@ -18,27 +19,42 @@ const COLLAPSE_THRESHOLD = 16;
*
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a
* request per keystroke.
* request per keystroke. Pressing Enter or clicking the 搜索 button flushes the
* pending debounce immediately, so users never wait on the timer.
*
* Pass ``searching`` while a query is in flight to show an inline spinner — it
* makes server-side searches feel responsive instead of looking like they kept
* the previous results.
*
* Tags are rendered inline as a multi-select chip row (instead of a dropdown):
* "全部" is selected when no tag is picked, and any number of tags can be
* toggled. Filtering is OR — a resource matches if it carries any picked tag.
* When there are many tags the row collapses behind a 展开/收起 toggle.
* When there are many tags the row collapses behind a 展开/收起 toggle. Pass
* ``hideTags`` for resources without tags (e.g. the published-task plaza).
*/
export function TagSearchBar({
targetType,
onSearchChange,
tagIds,
tagIds = [],
onTagIdsChange,
className,
actions,
searching = false,
hideTags = false,
searchPlaceholder,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagIds: string[];
onTagIdsChange: (value: string[]) => void;
tagIds?: string[];
onTagIdsChange?: (value: string[]) => void;
className?: string;
actions?: ReactNode;
/** Show an inline spinner while the caller's query is fetching. */
searching?: boolean;
/** Hide the tag chip row for resources that have no tags. */
hideTags?: boolean;
/** Override the search input placeholder. */
searchPlaceholder?: string;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
@@ -47,11 +63,21 @@ export function TagSearchBar({
const [expanded, setExpanded] = useState(false);
const cbRef = useRef(onSearchChange);
cbRef.current = onSearchChange;
// Keep the pending debounce timer so an explicit search (Enter / 搜索 button)
// can cancel it and fire immediately instead of racing the 300ms delay.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const id = setTimeout(() => cbRef.current(input.trim()), 300);
return () => clearTimeout(id);
timerRef.current = setTimeout(() => cbRef.current(input.trim()), 300);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [input]);
const flushSearch = () => {
if (timerRef.current) clearTimeout(timerRef.current);
cbRef.current(input.trim());
};
const allTags = tags ?? [];
const canCollapse = allTags.length > COLLAPSE_THRESHOLD;
// When collapsed, always keep the selected tags visible so the active
@@ -62,7 +88,7 @@ export function TagSearchBar({
: allTags;
const toggleTag = (id: string) => {
onTagIdsChange(
onTagIdsChange?.(
tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id],
);
};
@@ -75,8 +101,14 @@ export function TagSearchBar({
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
flushSearch();
}
}}
placeholder={searchPlaceholder ?? t.adminTags.searchPlaceholder}
className={cn("pl-8", searching && "pr-9")}
name="tag-search"
// A `type="password"` field elsewhere on the page (e.g. the private
// square access gate) makes browsers / password managers pick the
@@ -88,7 +120,24 @@ export function TagSearchBar({
data-lpignore="true"
data-form-type="other"
/>
{searching && (
<Loader2 className="text-muted-foreground absolute right-2.5 top-1/2 size-4 -translate-y-1/2 animate-spin" />
)}
</div>
<Button
type="button"
variant="outline"
onClick={flushSearch}
className="shrink-0 gap-1.5"
title="搜索"
>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<SearchIcon className="size-4" />
)}
搜索
</Button>
{actions && (
<div className="flex w-full shrink-0 justify-end sm:w-auto">
{actions}
@@ -96,11 +145,11 @@ export function TagSearchBar({
)}
</div>
{allTags.length > 0 && (
{!hideTags && allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => onTagIdsChange([])}
onClick={() => onTagIdsChange?.([])}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all",
tagIds.length === 0
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
@@ -161,6 +161,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/knowledge")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/knowledge">
<BookOpen />
<span>知识库</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</>
)}
</SidebarMenu>
@@ -9,15 +9,12 @@ import {
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon,
UsersIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -148,36 +145,6 @@ export function WorkspaceNavMenu() {
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/recommended-questions")
}
>
<MessageSquarePlusIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/private-square")
}
>
<ShieldCheckIcon />
私密广场配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
@@ -200,10 +167,12 @@ export function WorkspaceNavMenu() {
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<UsersIcon />
用户中心
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
@@ -232,12 +201,12 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>