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:
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={() =>
|
||||
|
||||
Reference in new issue
Block a user