Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
d03f5fbbc0
79 files changed
+9502
-1647
No files matched your search
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { ChatProvider } from "@/contexts/ChatContext";
|
||||
@@ -31,13 +32,18 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
{/* QueryClientProvider must wrap ThemeProvider because the custom
|
||||
ThemeProvider reads the admin-configured default via useQuery. */}
|
||||
<QueryClientProvider>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<AuthSyncWatcher />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
@@ -175,7 +176,7 @@ const menuItems: MenuItem[] = [
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "任务管理",
|
||||
label: "定时任务",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
@@ -193,6 +194,13 @@ const menuItems: MenuItem[] = [
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-private-square",
|
||||
label: "私密广场配置",
|
||||
icon: ShieldCheck,
|
||||
path: "/page/strategy/settings/private-square",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
TagsIcon,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
useAgent,
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useSetAgentPrivate,
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
@@ -62,6 +64,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const updateAgent = useUpdateAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const setPrivate = useSetAgentPrivate();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -70,6 +73,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
const [editPublished, setEditPublished] = useState(agent.published);
|
||||
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
agent.skills ?? [],
|
||||
);
|
||||
@@ -89,11 +93,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const isOwned =
|
||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||
const isFromOthers = !isBuiltin && !isOwned;
|
||||
// System admins are allowed to edit and delete built-in agents.
|
||||
// System admins are allowed to edit and delete any agent (built-in or owned
|
||||
// by another user) so the admin console can manage the full catalog.
|
||||
// Publish-toggle stays owner-only — built-ins are inherently visible to
|
||||
// everyone so toggling published is moot.
|
||||
const canEdit = isOwned || (isBuiltin && isAdmin);
|
||||
const canDelete = isOwned || (isBuiltin && isAdmin);
|
||||
const canEdit = isOwned || isAdmin;
|
||||
const canDelete = isOwned || isAdmin;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
@@ -102,9 +107,11 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
setEditName(agent.name);
|
||||
setEditDescription(agent.description ?? "");
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
}, [
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
agent.name,
|
||||
agent.published,
|
||||
agent.skills,
|
||||
@@ -145,6 +152,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (editIsPrivate && editPublished) {
|
||||
toast.warning("私密广场与公开发布互斥,请只勾选其一");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -152,7 +163,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
name: trimmedName,
|
||||
description: editDescription,
|
||||
soul: editSoul,
|
||||
published: editPublished,
|
||||
// Private agents cannot also be published — force the value the
|
||||
// backend will see, regardless of any stale state in editPublished.
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
skills: selectedSkills,
|
||||
},
|
||||
});
|
||||
@@ -164,6 +178,13 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
|
||||
async function handleTogglePublished() {
|
||||
// Private and public squares are mutually exclusive — a private agent
|
||||
// can't simultaneously be published to everyone. Block the toggle
|
||||
// instead of silently moving the agent between squares.
|
||||
if (!agent.published && agent.is_private) {
|
||||
toast.warning("该智能体已加入私密广场,不可发布");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -175,6 +196,27 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePrivate() {
|
||||
const turningOn = !agent.is_private;
|
||||
try {
|
||||
if (isOwned) {
|
||||
// Owners can re-categorize their own agents through the normal update
|
||||
// endpoint — keeps publish/private edits in one transaction.
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { is_private: turningOn },
|
||||
});
|
||||
} else {
|
||||
// Admins can move any agent (built-in or other users') via the
|
||||
// dedicated admin endpoint.
|
||||
await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn });
|
||||
}
|
||||
toast.success(turningOn ? "已加入私密广场" : "已移出私密广场");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured() {
|
||||
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
||||
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
||||
@@ -249,7 +291,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
来自他人
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && agent.published && (
|
||||
{isOwned && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
@@ -259,6 +301,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title="该智能体位于私密广场,需要密码才能查看"
|
||||
>
|
||||
<ShieldIcon className="size-3" />
|
||||
私密
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -314,13 +366,9 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
|
||||
<Button size="sm" className="flex-1" onClick={handleChat}>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -368,6 +416,24 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(isOwned || isAdmin) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
agent.is_private &&
|
||||
"text-purple-600 hover:text-purple-700",
|
||||
)}
|
||||
onClick={() => void handleTogglePrivate()}
|
||||
disabled={updateAgent.isPending || setPrivate.isPending}
|
||||
title={
|
||||
agent.is_private ? "移出私密广场" : "放入私密广场"
|
||||
}
|
||||
>
|
||||
<ShieldIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -392,6 +458,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="w-full" onClick={handleChat}>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -491,12 +561,41 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
打开后,该智能体对所有用户可见。
|
||||
{editIsPrivate
|
||||
? "已加入私密广场,不可发布;请先关闭私密广场。"
|
||||
: "打开后,该智能体对所有用户可见。"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editPublished}
|
||||
onCheckedChange={setEditPublished}
|
||||
checked={editPublished && !editIsPrivate}
|
||||
disabled={editIsPrivate}
|
||||
onCheckedChange={(v) => {
|
||||
if (editIsPrivate) {
|
||||
toast.warning("该智能体已加入私密广场,不可发布");
|
||||
return;
|
||||
}
|
||||
setEditPublished(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">加入私密广场</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editIsPrivate}
|
||||
onCheckedChange={(v) => {
|
||||
setEditIsPrivate(v);
|
||||
if (v) {
|
||||
// Mutual-exclusion: switching the agent into the private
|
||||
// square automatically clears the public-publish flag so
|
||||
// the user can't end up with a half-and-half state.
|
||||
setEditPublished(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useAgents, usePrivateAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
// Within a single tab the verified password is kept in sessionStorage so a
|
||||
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
|
||||
// password never persists beyond the browsing session.
|
||||
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||
"mine",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
const { agents, isLoading } = useAgents({ search, tagIds });
|
||||
// Tag filtering stays server-side; name search must run on the *desensitized*
|
||||
// form because that's what the user sees on the card. If we sent the search
|
||||
// string to the backend it would compare against the raw stored name and
|
||||
// miss every desensitized hit (e.g. typing "TS分析" wouldn't match the
|
||||
// stored "态势分析").
|
||||
const { agents, isLoading } = useAgents({ tagIds });
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
const matchesSearch = (a: Agent) =>
|
||||
!needle || desensitize(a.name).toLowerCase().includes(needle);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
const privateFeatureUsable =
|
||||
privateFeatureAvailable && !!privateStatus?.password_set;
|
||||
|
||||
const [privatePassword, setPrivatePassword] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.sessionStorage.getItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||
});
|
||||
const [passwordInput, setPasswordInput] = useState("");
|
||||
|
||||
const {
|
||||
agents: privateAgents,
|
||||
isLoading: isPrivateLoading,
|
||||
error: privateError,
|
||||
} = usePrivateAgents(privatePassword);
|
||||
|
||||
// If the server reports the stored password no longer works (likely the
|
||||
// admin rotated it), drop it from session storage and re-prompt.
|
||||
useEffect(() => {
|
||||
if (privatePassword && privateError) {
|
||||
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||
setPrivatePassword(null);
|
||||
toast.error("私密广场密码已失效,请重新输入");
|
||||
}
|
||||
}, [privateError, privatePassword]);
|
||||
|
||||
// The "广场" tab must never surface private-square agents, even to their
|
||||
// own owner — moving an agent into the private square is supposed to hide
|
||||
// it from the public square entirely. (The backend listing endpoint
|
||||
// already filters them out for non-owners; this extra clause covers the
|
||||
// owner's own private agents, which still come back in the unified list
|
||||
// because owners always see their own.)
|
||||
const allAgents = useMemo(
|
||||
() => agents.filter((a) => a.builtin || a.published),
|
||||
[agents],
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a),
|
||||
),
|
||||
// matchesSearch closes over `needle`; depending on `search` is sufficient
|
||||
// and avoids re-running on every render via the function reference.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agents, search],
|
||||
);
|
||||
|
||||
const myAgents = useMemo(
|
||||
() => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId),
|
||||
[agents, currentUserId],
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) =>
|
||||
a.user_id !== null &&
|
||||
a.user_id === currentUserId &&
|
||||
matchesSearch(a),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agents, currentUserId, search],
|
||||
);
|
||||
|
||||
const filteredPrivateAgents = useMemo(() => {
|
||||
const wanted = new Set(tagIds);
|
||||
return privateAgents.filter((a) => {
|
||||
if (!matchesSearch(a)) return false;
|
||||
if (wanted.size > 0) {
|
||||
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [privateAgents, search, tagIds]);
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/page/workspace/agents/new");
|
||||
};
|
||||
|
||||
const handleSubmitPassword = () => {
|
||||
const trimmed = passwordInput.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("请输入密码");
|
||||
return;
|
||||
}
|
||||
window.sessionStorage.setItem(PRIVATE_SQUARE_PASSWORD_KEY, trimmed);
|
||||
setPrivatePassword(trimmed);
|
||||
setPasswordInput("");
|
||||
};
|
||||
|
||||
const handleLockPrivate = () => {
|
||||
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||
setPrivatePassword(null);
|
||||
};
|
||||
|
||||
const renderGrid = (list: typeof agents) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
@@ -65,6 +159,78 @@ export function AgentGallery() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPrivateTab = () => {
|
||||
if (!privateFeatureAvailable) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="font-medium">私密广场尚未开启</p>
|
||||
<p className="text-muted-foreground max-w-sm text-sm">
|
||||
请管理员前往「综合管理 → 私密广场配置」开启该功能并设置密码。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!privateFeatureUsable) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="font-medium">私密广场未配置密码</p>
|
||||
<p className="text-muted-foreground max-w-sm text-sm">
|
||||
请管理员在私密广场配置页设置一个访问密码。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!privatePassword) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4 pt-12 text-center">
|
||||
<div className="bg-muted mx-auto flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<LockIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">私密广场</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
请输入访问密码,验证通过后才能查看其中的智能体。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="访问密码"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmitPassword();
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPrivateLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredPrivateAgents.length === 0) {
|
||||
return renderEmpty("私密广场暂时还没有智能体。");
|
||||
}
|
||||
|
||||
return renderGrid(filteredPrivateAgents);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
@@ -84,14 +250,35 @@ export function AgentGallery() {
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "all" | "mine" | "private")
|
||||
}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
{privateFeatureAvailable && (
|
||||
<TabsTrigger value="private">
|
||||
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
||||
私密广场
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{activeTab === "private" && privatePassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLockPrivate}
|
||||
title="锁定私密广场,下次需要重新输入密码"
|
||||
>
|
||||
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
锁定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
@@ -123,6 +310,15 @@ export function AgentGallery() {
|
||||
renderGrid(myAgents)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{renderPrivateTab()}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,12 @@ export function AgentManageDialog({
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const existing = new Set(agents.map((a) => a.id));
|
||||
return allAgents.filter((a) => a.published && !existing.has(a.id));
|
||||
// Private-square agents are hidden from the homepage selector even if
|
||||
// they're published — the backend ``list_featured`` filter would drop
|
||||
// them again on the way out, so offering them here is just confusing.
|
||||
return allAgents.filter(
|
||||
(a) => a.published && !a.is_private && !existing.has(a.id),
|
||||
);
|
||||
}, [agents, allAgents]);
|
||||
|
||||
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -10,6 +11,15 @@ import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -19,9 +29,38 @@ import {
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||
|
||||
type BackendReferenceBatch = {
|
||||
id: string;
|
||||
assistant_message_id?: string;
|
||||
sources: BackendReferenceSource[];
|
||||
};
|
||||
|
||||
function logReferenceDebug(
|
||||
event: string,
|
||||
threadId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
console.info(`[RAG引用调试] ${event}`, payload);
|
||||
void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event,
|
||||
thread_id: threadId,
|
||||
payload,
|
||||
}),
|
||||
}).catch(() => {
|
||||
// Debug logging must never affect the chat flow.
|
||||
});
|
||||
}
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
@@ -30,7 +69,20 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
const manualReferenceBatchRef = useRef(false);
|
||||
const referenceDebugSignatureRef = useRef<string>("");
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
@@ -43,14 +95,161 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
const fallbackReferenceBatches = useMemo(() => {
|
||||
if (!ragModeEnabled) {
|
||||
return [];
|
||||
}
|
||||
return buildTurnReferenceBatches(
|
||||
thread.values.messages ?? [],
|
||||
skillDisplayMap,
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
setBackendReferenceBatches([]);
|
||||
return;
|
||||
}
|
||||
if (thread.isLoading) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
logReferenceDebug("开始拉取后端参考文献", threadId, {
|
||||
messageCount: thread.values.messages?.length ?? 0,
|
||||
});
|
||||
apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load reference batches: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as { batches?: BackendReferenceBatch[] };
|
||||
})
|
||||
.then((data) => {
|
||||
const batches = data.batches ?? [];
|
||||
logReferenceDebug("后端参考文献返回", threadId, {
|
||||
batchCount: batches.length,
|
||||
sourceCounts: batches.map((batch) => batch.sources?.length ?? 0),
|
||||
batchIds: batches.map((batch) => batch.id),
|
||||
});
|
||||
setBackendReferenceBatches(batches);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn("Failed to load backend reference batches", error);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
const referenceBatches =
|
||||
backendReferenceBatches.length > 0
|
||||
? backendReferenceBatches
|
||||
: fallbackReferenceBatches;
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify({
|
||||
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
});
|
||||
if (referenceDebugSignatureRef.current === signature) {
|
||||
return;
|
||||
}
|
||||
referenceDebugSignatureRef.current = signature;
|
||||
logReferenceDebug("前端参考文献来源选择", threadId, {
|
||||
using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底",
|
||||
backendBatchCount: backendReferenceBatches.length,
|
||||
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
fallbackBatchCount: fallbackReferenceBatches.length,
|
||||
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
finalBatchCount: referenceBatches.length,
|
||||
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
});
|
||||
}, [backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
const latestReferenceBatch =
|
||||
referenceBatches.length > 0
|
||||
? referenceBatches[referenceBatches.length - 1]!
|
||||
: null;
|
||||
const activeReferenceBatch =
|
||||
referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ??
|
||||
latestReferenceBatch;
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
}
|
||||
}, [hasCitationReferences]);
|
||||
useEffect(() => {
|
||||
if (!latestReferenceBatch) {
|
||||
setActiveReferenceBatchId(null);
|
||||
latestReferenceBatchIdRef.current = null;
|
||||
manualReferenceBatchRef.current = false;
|
||||
return;
|
||||
}
|
||||
const previousLatestId = latestReferenceBatchIdRef.current;
|
||||
latestReferenceBatchIdRef.current = latestReferenceBatch.id;
|
||||
setActiveReferenceBatchId((current) =>
|
||||
current &&
|
||||
referenceBatches.some((batch) => batch.id === current) &&
|
||||
manualReferenceBatchRef.current &&
|
||||
current !== previousLatestId
|
||||
? current
|
||||
: latestReferenceBatch.id,
|
||||
);
|
||||
}, [latestReferenceBatch?.id, referenceBatches]);
|
||||
useEffect(() => {
|
||||
const handleShowReference = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ batchId?: string; referenceId?: string }>).detail;
|
||||
if (detail?.batchId) {
|
||||
manualReferenceBatchRef.current = true;
|
||||
setActiveReferenceBatchId(detail.batchId);
|
||||
}
|
||||
setPinnedReferenceId(null);
|
||||
window.setTimeout(() => {
|
||||
setPinnedReferenceId(detail?.referenceId ?? null);
|
||||
}, 0);
|
||||
setReferencesOpen(true);
|
||||
setSideTab("references");
|
||||
};
|
||||
window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
return () => {
|
||||
window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
artifactsCountRef.current = 0;
|
||||
deselect();
|
||||
setReferencesOpen(false);
|
||||
setActiveReferenceBatchId(null);
|
||||
setPinnedReferenceId(null);
|
||||
setBackendReferenceBatches([]);
|
||||
latestReferenceBatchIdRef.current = null;
|
||||
manualReferenceBatchRef.current = false;
|
||||
referenceDebugSignatureRef.current = "";
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(thread.values.artifacts ?? []);
|
||||
const nextArtifacts = thread.values.artifacts ?? [];
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
@@ -85,6 +284,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArtifact) {
|
||||
setSideTab("document");
|
||||
} else if (hasCitationReferences) {
|
||||
setSideTab("references");
|
||||
}
|
||||
}, [hasCitationReferences, selectedArtifact]);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
const raw = pathname
|
||||
@@ -95,13 +304,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (artifactPanelOpen) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [artifactPanelOpen]);
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
@@ -117,28 +326,79 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
!sidePanelOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
sidePanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
{showTabs ? (
|
||||
<Tabs value={sideTab} onValueChange={setSideTab} className="flex size-full flex-col overflow-hidden">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="document">文档</TabsTrigger>
|
||||
<TabsTrigger value="references">参考文献</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
@@ -180,4 +440,166 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function ReferencePanel({
|
||||
highlightedReferenceId,
|
||||
sources,
|
||||
}: {
|
||||
highlightedReferenceId?: string | null;
|
||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
||||
}) {
|
||||
const orderedSources = useMemo(() => {
|
||||
if (!highlightedReferenceId) {
|
||||
return sources;
|
||||
}
|
||||
const index = Number(highlightedReferenceId);
|
||||
if (!Number.isFinite(index)) {
|
||||
return sources;
|
||||
}
|
||||
const selected = sources.find((source) => source.index === index);
|
||||
if (!selected) {
|
||||
return sources;
|
||||
}
|
||||
return [selected, ...sources.filter((source) => source.index !== index)];
|
||||
}, [highlightedReferenceId, sources]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<header className="shrink-0 px-1 pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
||||
参考文献
|
||||
</h2>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{sources.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
||||
点击编号或卡片可查看引用详情。
|
||||
</p>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
|
||||
<SkillDisplaySources
|
||||
highlightedReferenceId={highlightedReferenceId}
|
||||
sources={orderedSources}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildTurnReferenceBatches(
|
||||
messages: Message[],
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
isLoading: boolean,
|
||||
): BackendReferenceBatch[] {
|
||||
const batches: BackendReferenceBatch[] = [];
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const human = messages[index];
|
||||
if (human?.type !== "human" || !human.id) {
|
||||
continue;
|
||||
}
|
||||
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
|
||||
if (nextHumanIndex === -1 && isLoading) {
|
||||
continue;
|
||||
}
|
||||
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
|
||||
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
|
||||
if (!assistant?.id) {
|
||||
continue;
|
||||
}
|
||||
const sources = collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: human.id,
|
||||
beforeMessageId:
|
||||
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
|
||||
includeBeforeMessage: false,
|
||||
includeModes: ["citation"],
|
||||
}).filter((source) => source.mode === "citation");
|
||||
if (sources.length > 0) {
|
||||
batches.push({
|
||||
id: assistant.id,
|
||||
assistant_message_id: assistant.id,
|
||||
sources,
|
||||
});
|
||||
}
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
function findNextHumanIndex(messages: Message[], start: number): number {
|
||||
for (let index = start; index < messages.length; index++) {
|
||||
if (messages[index]?.type === "human") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findLastAssistantAnswer(
|
||||
messages: Message[],
|
||||
start: number,
|
||||
end: number,
|
||||
): Message | null {
|
||||
for (let index = end - 1; index >= start; index--) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message?.type === "ai" &&
|
||||
message.id &&
|
||||
hasAssistantAnswerContent(message) &&
|
||||
!hasAssistantToolCalls(message)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAssistantAnswerContent(message: Message): boolean {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
return content.some((part) => {
|
||||
if (!part || typeof part !== "object") {
|
||||
return false;
|
||||
}
|
||||
const block = part as Record<string, unknown>;
|
||||
return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hasAssistantToolCalls(message: Message): boolean {
|
||||
const direct = (message as { tool_calls?: unknown }).tool_calls;
|
||||
if (Array.isArray(direct) && direct.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const chunks = (message as { tool_call_chunks?: unknown }).tool_call_chunks;
|
||||
if (Array.isArray(chunks) && chunks.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const kwargs = (message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs;
|
||||
const extra = kwargs?.tool_calls;
|
||||
return Array.isArray(extra) && extra.length > 0;
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
export function useThreadChat() {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const pathname = usePathname();
|
||||
// ``location.key`` is regenerated on every react-router navigation, even when
|
||||
// the resolved pathname is identical. We depend on it so that clicking
|
||||
// "新对话" while already at /chats/new still triggers a fresh thread reset
|
||||
// (otherwise navigate-to-same-path is silently a no-op and the in-flight
|
||||
// conversation would persist on screen).
|
||||
const locationKey = useLocation().key;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const [threadId, setThreadId] = useState(() => {
|
||||
@@ -37,7 +44,7 @@ export function useThreadChat() {
|
||||
}
|
||||
setIsNewThread(false);
|
||||
setThreadId(threadIdFromPath);
|
||||
}, [pathname, threadIdFromPath]);
|
||||
}, [pathname, threadIdFromPath, locationKey]);
|
||||
const isMock = searchParams.get("mock") === "true";
|
||||
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
|
||||
}
|
||||
@@ -1,82 +1,77 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const domain = extractDomain(href ?? "");
|
||||
|
||||
// Priority: children > domain
|
||||
const childrenText =
|
||||
typeof children === "string"
|
||||
? children.replace(/^citation:\s*/i, "")
|
||||
: null;
|
||||
const isGenericText = childrenText === "Source" || childrenText === "来源";
|
||||
const displayText = (!isGenericText && childrenText) ?? domain;
|
||||
|
||||
return (
|
||||
<HoverCard closeDelay={0} openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
|
||||
>
|
||||
{displayText}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Badge>
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
|
||||
<div className="p-3">
|
||||
<div className="space-y-1">
|
||||
{displayText && (
|
||||
<h4 className="truncate text-sm leading-tight font-medium">
|
||||
{displayText}
|
||||
</h4>
|
||||
)}
|
||||
{href && (
|
||||
<p className="text-muted-foreground truncate text-xs break-all">
|
||||
{href}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
Visit source
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./i, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const childrenText = normalizeCitationText(children);
|
||||
const displayText = childrenText ?? "";
|
||||
const referenceId = href?.startsWith("#reference-")
|
||||
? href.slice("#reference-".length)
|
||||
: null;
|
||||
|
||||
if (referenceId) {
|
||||
const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
title={`查看参考文献 ${badgeLabel}`}
|
||||
className={cn(
|
||||
"border-primary/30 bg-primary/10 text-primary hover:border-primary/60 hover:bg-primary/20 focus-visible:ring-primary/30 mx-0.5 inline-flex h-[18px] min-w-[18px] -translate-y-[1px] items-center justify-center rounded-full border px-1 text-[10.5px] font-semibold tabular-nums no-underline transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
const batchId =
|
||||
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||
?.dataset.referenceBatchId;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SHOW_REFERENCE_EVENT, {
|
||||
detail: { batchId, referenceId },
|
||||
}),
|
||||
);
|
||||
const scroll = () => {
|
||||
const target = document.getElementById(`reference-${referenceId}`);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
window.setTimeout(scroll, 240);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{badgeLabel}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href?.startsWith("http") ? "_blank" : undefined}
|
||||
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayText || children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
|
||||
if (typeof children !== "string") {
|
||||
return null;
|
||||
}
|
||||
let text = children.trim();
|
||||
while (/^citation:\s*/i.test(text)) {
|
||||
text = text.replace(/^citation:\s*/i, "");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
@@ -86,6 +87,11 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
@@ -177,6 +183,7 @@ export function InputBox({
|
||||
};
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
@@ -297,6 +304,15 @@ export function InputBox({
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
}
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -308,9 +324,12 @@ export function InputBox({
|
||||
}
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
setFollowupsLoading(false);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
onContextChange?.({
|
||||
@@ -332,11 +351,13 @@ export function InputBox({
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
],
|
||||
);
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
const form = promptRootRef.current?.querySelector("form");
|
||||
@@ -622,6 +643,33 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleRagModeToggle}
|
||||
>
|
||||
{ragModeEnabled && (
|
||||
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
|
||||
)}
|
||||
<BookMarkedIcon className="size-3" />
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
|
||||
@@ -36,10 +36,13 @@ export function MarkdownContent({
|
||||
}: MarkdownContentProps) {
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (props.href?.startsWith("#reference-")) {
|
||||
return <CitationLink {...props} />;
|
||||
}
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
const [, text] = match;
|
||||
return <CitationLink {...props}>{text}</CitationLink>;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,17 @@ import {
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -54,6 +65,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
@@ -74,6 +86,12 @@ export function MessageGroup({
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
return filteredSteps[filteredSteps.length - 1];
|
||||
@@ -142,7 +160,13 @@ export function MessageGroup({
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
) : (
|
||||
<ToolCall key={step.id} {...step} isLoading={isLoading} />
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
@@ -152,6 +176,8 @@ export function MessageGroup({
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
@@ -209,6 +235,8 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@@ -217,11 +245,56 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -544,6 +617,28 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
|
||||
@@ -36,15 +36,20 @@ import {
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { CopyButton } from "../copy-button";
|
||||
|
||||
import { SkillDisplayBlock } from "../skill-display-block";
|
||||
import { ThreadContext } from "./context";
|
||||
import { DownloadWordButton } from "./download-word-button";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
|
||||
import { MessageTokenUsage } from "./message-token-usage";
|
||||
|
||||
/**
|
||||
@@ -76,6 +81,7 @@ export function MessageListItem({
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -83,6 +89,7 @@ export function MessageListItem({
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const isHuman = message.type === "human";
|
||||
@@ -101,6 +108,7 @@ export function MessageListItem({
|
||||
isLoading={isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
/>
|
||||
{!isLoading && (
|
||||
@@ -162,6 +170,7 @@ function MessageContent_({
|
||||
isLoading = false,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -169,6 +178,7 @@ function MessageContent_({
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
@@ -184,6 +194,12 @@ function MessageContent_({
|
||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (href?.startsWith("/mnt/")) {
|
||||
const url = resolveArtifact(href);
|
||||
return (
|
||||
@@ -220,8 +236,8 @@ function MessageContent_({
|
||||
if (isHuman) {
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
return applyInlineCitations(rawContent ?? "", displaySources);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
|
||||
const filesList =
|
||||
files && files.length > 0 ? (
|
||||
@@ -287,13 +303,15 @@ function MessageContent_({
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<MarkdownContent
|
||||
content={contentToDisplay}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
<div data-reference-batch-id={message.id} className="contents">
|
||||
<AssistantDisplayContent
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={isLoading}
|
||||
@@ -470,3 +488,159 @@ function RichFileCard({
|
||||
}
|
||||
|
||||
const MessageContent = memo(MessageContent_);
|
||||
|
||||
function AssistantDisplayContent({
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MarkdownContentProps["rehypePlugins"];
|
||||
components: MarkdownContentProps["components"];
|
||||
}) {
|
||||
const { parts, placedKeys } = useMemo(
|
||||
() => splitSkillDisplayPlaceholders(content, displaySources),
|
||||
[content, displaySources],
|
||||
);
|
||||
const fallbackBlocks = useMemo(() => {
|
||||
return groupDisplaySources(displaySources).filter(
|
||||
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
|
||||
);
|
||||
}, [displaySources, placedKeys]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.type === "markdown" ? (
|
||||
<MarkdownContent
|
||||
key={`markdown-${index}`}
|
||||
content={part.content}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
<SkillDisplayBlock
|
||||
key={`display-${index}`}
|
||||
mode={part.mode}
|
||||
sources={part.sources}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{fallbackBlocks.map((group) => (
|
||||
<SkillDisplayBlock
|
||||
key={`fallback-${group.key}`}
|
||||
mode={group.mode}
|
||||
sources={group.sources}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DisplayPart =
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: "display";
|
||||
key: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
};
|
||||
|
||||
function splitSkillDisplayPlaceholders(
|
||||
content: string,
|
||||
displaySources: RuntimeDisplaySource[],
|
||||
): { parts: DisplayPart[]; placedKeys: Set<string> } {
|
||||
const pattern = /<skill-display\s+([^>]*?)\/>/g;
|
||||
const parts: DisplayPart[] = [];
|
||||
const placedKeys = new Set<string>();
|
||||
let cursor = 0;
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const start = match.index ?? 0;
|
||||
const before = content.slice(cursor, start);
|
||||
if (before.trim()) {
|
||||
parts.push({ type: "markdown", content: before });
|
||||
}
|
||||
const attrs = parseDisplayAttrs(match[1] ?? "");
|
||||
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
|
||||
if (group) {
|
||||
parts.push({
|
||||
type: "display",
|
||||
key: group.key,
|
||||
mode: group.mode,
|
||||
sources: group.sources,
|
||||
});
|
||||
placedKeys.add(group.key);
|
||||
}
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
const rest = content.slice(cursor);
|
||||
if (rest.trim() || parts.length === 0) {
|
||||
parts.push({ type: "markdown", content: rest });
|
||||
}
|
||||
return { parts, placedKeys };
|
||||
}
|
||||
|
||||
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
|
||||
attrs[match[1]!] = match[2]!;
|
||||
}
|
||||
return {
|
||||
tool: attrs.tool || attrs.skill || attrs.name,
|
||||
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function findDisplayGroup(
|
||||
sources: RuntimeDisplaySource[],
|
||||
tool?: string,
|
||||
mode?: RuntimeDisplaySource["mode"],
|
||||
) {
|
||||
return groupDisplaySources(sources).find((group) => {
|
||||
if (mode && group.mode !== mode) {
|
||||
return false;
|
||||
}
|
||||
if (!tool) {
|
||||
return true;
|
||||
}
|
||||
const left = normalizeName(group.skillName);
|
||||
const right = normalizeName(tool);
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
});
|
||||
}
|
||||
|
||||
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
skillName: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
}
|
||||
>();
|
||||
for (const source of sources) {
|
||||
const key = `${source.skillName}:${source.mode}`;
|
||||
const group =
|
||||
groups.get(key) ??
|
||||
{
|
||||
key,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
sources: [],
|
||||
};
|
||||
group.sources.push(source);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
hasToolCalls,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
@@ -63,7 +70,6 @@ export function MessageList({
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
@@ -71,6 +77,14 @@ export function MessageList({
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
@@ -190,6 +204,16 @@ export function MessageList({
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const displaySources =
|
||||
ragModeEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai"
|
||||
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
})
|
||||
: [];
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
@@ -197,6 +221,7 @@ export function MessageList({
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
/>
|
||||
);
|
||||
@@ -442,6 +467,24 @@ export type SetupAgentApprovalPayload = {
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getSetupAgentApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupAgentApprovalPayload | null {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,9 +16,17 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -31,6 +41,28 @@ export function AppearanceSettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
toast.success(
|
||||
value === "light"
|
||||
? "已将系统默认主题设置为浅色"
|
||||
: "已将系统默认主题设置为深色",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
@@ -76,6 +108,43 @@ export function AppearanceSettingsPage() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认主题(管理员)"
|
||||
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("light")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<SunIcon className="mr-1.5 size-4" />
|
||||
浅色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("dark")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<MoonIcon className="mr-1.5 size-4" />
|
||||
深色
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light" ? "浅色" : "深色"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
@@ -125,6 +194,44 @@ export function AppearanceSettingsPage() {
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium">参考文献去除词</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
|
||||
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={cleanupWords.join("\n")}
|
||||
onChange={(event) => {
|
||||
const words = event.currentTarget.value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
setSettings("display", { citationCleanupWords: words });
|
||||
updateCitationDisplay.mutate({ cleanup_words: words });
|
||||
}}
|
||||
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
|
||||
className="min-h-32 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({
|
||||
export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
emptyDisplayConfig,
|
||||
inferDisplayConfig,
|
||||
normalizeDisplaySources,
|
||||
} from "@/core/skill-display/display-adapter";
|
||||
import {
|
||||
getSkill,
|
||||
getPersistedSkillDisplaySample,
|
||||
getSkillDisplaySample,
|
||||
} from "@/core/skills/api";
|
||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||
|
||||
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
["resultPath", "结果列表路径"],
|
||||
["titleField", "标题字段"],
|
||||
["summaryField", "摘要字段"],
|
||||
["contentField", "正文内容字段"],
|
||||
["sourceField", "来源字段"],
|
||||
["typeField", "类型字段"],
|
||||
["timeField", "时间字段"],
|
||||
["scoreField", "相关度字段"],
|
||||
["urlField", "URL 跳转字段"],
|
||||
["idField", "ID 跳转字段"],
|
||||
["linkTemplate", "ID 跳转链接模板"],
|
||||
];
|
||||
|
||||
const displayModeLabels: Record<SkillDisplayMode, string> = {
|
||||
citation: "编号引用",
|
||||
list: "列表展示",
|
||||
table: "表格展示",
|
||||
card: "卡片展示",
|
||||
custom: "自定义展示",
|
||||
none: "不展示",
|
||||
};
|
||||
|
||||
const linkStrategyLabels: Record<SkillLinkStrategy, string> = {
|
||||
auto: "自动判断",
|
||||
url: "使用 URL 字段跳转",
|
||||
id: "使用 ID 模板跳转",
|
||||
};
|
||||
|
||||
function updateConfig<K extends keyof SkillDisplayConfig>(
|
||||
config: SkillDisplayConfig,
|
||||
key: K,
|
||||
value: SkillDisplayConfig[K],
|
||||
): SkillDisplayConfig {
|
||||
return { ...config, [key]: value };
|
||||
}
|
||||
|
||||
function formatSample(sample: Record<string, unknown> | null | undefined) {
|
||||
return sample ? JSON.stringify(sample, null, 2) : "";
|
||||
}
|
||||
|
||||
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
const queryClient = useQueryClient();
|
||||
const setDisplay = useSetSkillDisplay();
|
||||
const lastSkillNameRef = useRef(skill.name);
|
||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
|
||||
|
||||
// Echo server state into the form — on mount, on skill switch, AND after
|
||||
// a successful save (the refetched skill object has a new `display` identity).
|
||||
// Important: this drives the "保存后回显" behavior. Without depending on
|
||||
// `skill.display`, any backend-side normalization (e.g. default values
|
||||
// backfilled by Pydantic, column reordering) would not flow back into the UI.
|
||||
useEffect(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
}, [skill.name, skill.display]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadDbSkillConfig() {
|
||||
setLoadingDbConfig(true);
|
||||
try {
|
||||
const fresh = await getSkill(skill.name);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const display = fresh.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(fresh.displaySample));
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skill.name ? { ...item, ...fresh } : item))
|
||||
: current,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingDbConfig(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadDbSkillConfig();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [queryClient, skill.name]);
|
||||
|
||||
// Sample text is editor-only state — never reset on save. Only seed it once
|
||||
// per skill, otherwise the user loses the JSON they just pulled in via
|
||||
// 自动识别 every time they hit 保存.
|
||||
useEffect(() => {
|
||||
if (lastSkillNameRef.current !== skill.name) {
|
||||
lastSkillNameRef.current = skill.name;
|
||||
setSampleText(formatSample(skill.displaySample));
|
||||
return;
|
||||
}
|
||||
if (skill.displaySample) {
|
||||
setSampleText(formatSample(skill.displaySample));
|
||||
}
|
||||
}, [skill.name, skill.displaySample]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadPersistedSample() {
|
||||
try {
|
||||
const response = await getPersistedSkillDisplaySample(skill.name);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (response.sample) {
|
||||
setSampleText(formatSample(response.sample));
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
||||
current ? { ...current, displaySample: response.sample } : current,
|
||||
);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) =>
|
||||
item.name === skill.name
|
||||
? { ...item, displaySample: response.sample }
|
||||
: item,
|
||||
)
|
||||
: current,
|
||||
);
|
||||
} else if (!skill.displaySample) {
|
||||
setSampleText("");
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadPersistedSample();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [queryClient, skill.name, skill.displaySample]);
|
||||
|
||||
const samplePayload = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(sampleText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [sampleText]);
|
||||
|
||||
const previewItems = useMemo(() => (
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
||||
), [draft, samplePayload]);
|
||||
|
||||
function generateDraft() {
|
||||
if (!samplePayload) {
|
||||
toast.error("样例返回不是有效的 JSON");
|
||||
return;
|
||||
}
|
||||
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已生成展示适配草稿");
|
||||
}
|
||||
|
||||
async function loadSampleAndGenerate() {
|
||||
try {
|
||||
const response = await getSkillDisplaySample(skill.name);
|
||||
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
||||
setSampleText(nextSampleText);
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
||||
current ? { ...current, displaySample: response.sample } : current,
|
||||
);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) =>
|
||||
item.name === skill.name
|
||||
? { ...item, displaySample: response.sample }
|
||||
: item,
|
||||
)
|
||||
: current,
|
||||
);
|
||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已获取样例并生成展示适配");
|
||||
} catch (err) {
|
||||
generateDraft();
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
try {
|
||||
const saved = await setDisplay.mutateAsync({
|
||||
skillName: skill.name,
|
||||
display: draft,
|
||||
sample: samplePayload ?? undefined,
|
||||
});
|
||||
const display = saved.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(saved.displaySample));
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDisplay() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
||||
setDraft(emptyDisplayConfig());
|
||||
setTableColumnsText(JSON.stringify([], null, 2));
|
||||
toast.success("展示适配已清除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">技能展示适配</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
|
||||
</p>
|
||||
{loadingDbConfig && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
正在从数据库读取已保存的配置...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">启用</span>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) => setDraft(updateConfig(draft, "enabled", checked))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">展示方式</span>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(displayModeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">跳转方式</span>
|
||||
<Select
|
||||
value={draft.linkStrategy}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(linkStrategyLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">最多展示条数</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={draft.maxItems}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{fieldLabels.map(([field, label]) => (
|
||||
<label key={field} className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Input
|
||||
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
|
||||
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft.mode === "table" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
||||
<Textarea
|
||||
value={tableColumnsText}
|
||||
onChange={(event) => {
|
||||
setTableColumnsText(event.currentTarget.value);
|
||||
try {
|
||||
const value = JSON.parse(event.currentTarget.value);
|
||||
if (Array.isArray(value)) {
|
||||
setDraft(updateConfig(draft, "tableColumns", value));
|
||||
}
|
||||
} catch {
|
||||
// Keep typing invalid JSON without crashing the panel.
|
||||
}
|
||||
}}
|
||||
placeholder={'[{"label":"任务名称","field":"title"},{"label":"状态","field":"status"}]'}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{draft.mode === "custom" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
||||
<Textarea
|
||||
value={draft.customInstruction}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
||||
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
||||
className="min-h-24"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">技能返回样例</div>
|
||||
<Button size="sm" variant="outline" onClick={() => void loadSampleAndGenerate()}>
|
||||
<WandSparklesIcon className="size-3.5" />
|
||||
自动识别
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={sampleText}
|
||||
onChange={(event) => setSampleText(event.currentTarget.value)}
|
||||
className="min-h-72 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">展示预览</div>
|
||||
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||
{!samplePayload ? (
|
||||
sampleText.trim() ? (
|
||||
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">数据库暂无技能返回样例,请点击自动识别</div>
|
||||
)
|
||||
) : previewItems.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
||||
) : draft.mode === "citation" ? (
|
||||
<>
|
||||
<p className="text-sm leading-relaxed">
|
||||
编号引用预览:
|
||||
{previewItems.slice(0, 3).map((_item, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={idx}
|
||||
className="mx-1 inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-background px-2 text-xs font-semibold"
|
||||
>
|
||||
[{idx + 1}]
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
{previewItems.map((item, idx) => (
|
||||
<PreviewItem key={idx} index={idx + 1} item={item} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
previewItems.map((item, idx) => <PreviewItem key={idx} index={idx + 1} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => void clearDisplay()} disabled={setDisplay.isPending}>
|
||||
清除
|
||||
</Button>
|
||||
<Button onClick={() => void saveDraft()} disabled={setDisplay.isPending}>
|
||||
{setDisplay.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
保存展示适配
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewItem({
|
||||
index,
|
||||
item,
|
||||
}: {
|
||||
index: number;
|
||||
item: ReturnType<typeof normalizeDisplaySources>[number];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background p-3 text-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
[{index}] {item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{item.type}
|
||||
{item.source ? ` | ${item.source}` : ""}
|
||||
{item.time ? ` | ${item.time}` : ""}
|
||||
{typeof item.score === "number" ? ` | ${item.score.toFixed(3)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="打开链接"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 leading-relaxed text-muted-foreground">
|
||||
{item.snippet || item.content || "暂无摘要"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { SkillDisplayTableColumn } from "@/core/skills/type";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SourceCard } from "./skill-display-sources";
|
||||
|
||||
export function SkillDisplayBlock({
|
||||
mode,
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0 || mode === "citation" || mode === "none") {
|
||||
return null;
|
||||
}
|
||||
if (mode === "table") {
|
||||
return <SkillDisplayTable sources={sources} className={className} />;
|
||||
}
|
||||
return (
|
||||
<div className={cn("not-prose my-4 grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard key={`${source.skillName}-${source.index}`} source={source} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDisplayTable({
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<RuntimeDisplaySource | null>(null);
|
||||
const columns = useMemo(() => buildColumns(sources), [sources]);
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose my-4 overflow-hidden rounded-md border bg-card", className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.field}
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
className="cursor-pointer border-t transition-colors hover:bg-accent/40"
|
||||
onClick={() => setSelected(source)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.field} className="max-w-[280px] px-3 py-2 align-top">
|
||||
<span className="line-clamp-2 break-words">
|
||||
{formatCell(readByPath(source.raw, column.field))}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SourceDetailDialog source={selected} onOpenChange={(open) => !open && setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDetailDialog({
|
||||
source,
|
||||
onOpenChange,
|
||||
}: {
|
||||
source: RuntimeDisplaySource | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const content = source?.content || source?.snippet;
|
||||
const meta = source
|
||||
? [source.source, source.type, source.time].filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{source && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[45vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-muted/40 max-h-[32vh] overflow-auto rounded-md p-3 text-xs">
|
||||
{JSON.stringify(source.raw, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumns(sources: RuntimeDisplaySource[]): SkillDisplayTableColumn[] {
|
||||
const configured = sources[0]?.tableColumns?.filter((column) => column.field);
|
||||
if (configured && configured.length > 0) {
|
||||
return configured;
|
||||
}
|
||||
const raw = sources[0]?.raw ?? {};
|
||||
return Object.keys(raw)
|
||||
.slice(0, 6)
|
||||
.map((field) => ({ label: field, field }));
|
||||
}
|
||||
|
||||
function readByPath(value: unknown, path: string): unknown {
|
||||
if (!path) {
|
||||
return value;
|
||||
}
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
highlightedReferenceId?: string | null;
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
highlighted={highlightedReferenceId === String(source.index)}
|
||||
source={source}
|
||||
referenceTarget
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceCard({
|
||||
highlighted = false,
|
||||
source,
|
||||
referenceTarget = false,
|
||||
}: {
|
||||
highlighted?: boolean;
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
? `${Math.round(source.score * 100)}%`
|
||||
: String(source.score)
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:bg-primary/5 target:border-primary/60",
|
||||
highlighted &&
|
||||
"animate-[reference-flash_1.4s_ease-out] border-amber-400 bg-amber-50 shadow-[0_0_0_3px_rgba(251,191,36,0.22)]",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{isCitation && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="text-foreground line-clamp-2 text-sm leading-snug font-medium">
|
||||
{source.title}
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`} className="inline-flex items-center">
|
||||
{index > 0 && <span className="mr-2 opacity-50">·</span>}
|
||||
<span className="truncate">{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
{isCitation && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{paragraphs.length > 0 && (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{source.url && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{source.url}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanReferenceText(
|
||||
raw: string | undefined,
|
||||
cleanupWords: string[],
|
||||
): string {
|
||||
let text = raw ?? "";
|
||||
const words = cleanupWords.map((word) => word.trim()).filter(Boolean);
|
||||
if (words.length > 0) {
|
||||
text = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.filter((line) => !words.some((word) => line.includes(word)))
|
||||
.join("\n");
|
||||
}
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function compactReferenceText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function segmentReferenceText(text: string): string[] {
|
||||
const explicit = text
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||||
return base.slice(0, 80);
|
||||
}
|
||||
|
||||
function splitLongParagraph(text: string): string[] {
|
||||
const normalized = text
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (normalized.length <= 360) {
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
const sentences = normalized
|
||||
.split(/(?<=[。!?!?;;])\s*/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (sentences.length <= 1) {
|
||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
||||
}
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
let current = "";
|
||||
for (const sentence of sentences) {
|
||||
if ((current + sentence).length > 360 && current) {
|
||||
paragraphs.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
paragraphs.push(current.trim());
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileCode2Icon,
|
||||
FilesIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -25,12 +27,16 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
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 {
|
||||
useSetSkillDetail,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
useSkillFiles,
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
@@ -62,6 +68,18 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isSkillOwner(skill: Skill | null | undefined): boolean {
|
||||
if (!skill) return false;
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) return false;
|
||||
return (
|
||||
skill.category === "custom" &&
|
||||
!skill.builtin &&
|
||||
!!skill.owner_user_id &&
|
||||
skill.owner_user_id === auth.user_id
|
||||
);
|
||||
}
|
||||
|
||||
function stripYamlFrontmatter(content: string): string {
|
||||
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
||||
}
|
||||
@@ -83,22 +101,32 @@ export function SkillDetailDialog({
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
|
||||
null,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
>(null);
|
||||
const [showRawSource, setShowRawSource] = useState(false);
|
||||
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||
|
||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
||||
const {
|
||||
data: contentData,
|
||||
isLoading: isContentLoading,
|
||||
error: contentError,
|
||||
} = useSkillContent(open && canManage ? skillName : null);
|
||||
} = useSkillContent(open ? skillName : null);
|
||||
|
||||
const filesEnabled = open && isOwner && activeTab === "files";
|
||||
const {
|
||||
data: filesData,
|
||||
isLoading: isFilesLoading,
|
||||
error: filesError,
|
||||
} = useSkillFiles(filesEnabled ? skillName : null);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setActiveTab(null);
|
||||
setShowRawSource(false);
|
||||
setOpenedFilePath(null);
|
||||
}, [open, skillName]);
|
||||
|
||||
const skillBody = useMemo(() => {
|
||||
@@ -106,7 +134,7 @@ export function SkillDetailDialog({
|
||||
}, [contentData]);
|
||||
|
||||
const defaultTab: "detail" | "source" =
|
||||
canManage && skill && !skill.detail ? "source" : "detail";
|
||||
skill && !skill.detail ? "source" : "detail";
|
||||
const selectedTab = activeTab ?? defaultTab;
|
||||
|
||||
function startEditing() {
|
||||
@@ -131,6 +159,7 @@ export function SkillDetailDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||
@@ -194,11 +223,21 @@ export function SkillDetailDialog({
|
||||
{tx.detailSectionTitle}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
<TabsTrigger value="display" className="text-xs">
|
||||
<SlidersHorizontalIcon className="mr-1 size-3.5" />
|
||||
展示适配
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isOwner && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
<FilesIcon className="mr-1 size-3.5" />
|
||||
{tx.filesSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -214,7 +253,6 @@ export function SkillDetailDialog({
|
||||
</Button>
|
||||
)}
|
||||
{selectedTab === "source" &&
|
||||
canManage &&
|
||||
!isContentLoading &&
|
||||
!contentError && (
|
||||
<>
|
||||
@@ -299,7 +337,7 @@ export function SkillDetailDialog({
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||
<p className="text-sm">{tx.detailEmpty}</p>
|
||||
{canManage ? (
|
||||
{isOwner ? (
|
||||
<p className="mt-1 text-xs">
|
||||
Open the SKILL.md tab to view the skill source.
|
||||
</p>
|
||||
@@ -312,10 +350,47 @@ export function SkillDetailDialog({
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="source"
|
||||
value="display"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
<SkillDisplayAdapterPanel skill={skill} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<TabsContent
|
||||
value="files"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{isFilesLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : filesError ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{filesError instanceof Error
|
||||
? filesError.message
|
||||
: String(filesError)}
|
||||
</div>
|
||||
) : filesData ? (
|
||||
<SkillFileTree
|
||||
root={filesData.root}
|
||||
selectedPath={openedFilePath}
|
||||
onOpen={(path) => setOpenedFilePath(path)}
|
||||
emptyText={tx.filesEmpty}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent
|
||||
value="source"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{isContentLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
@@ -341,10 +416,19 @@ export function SkillDetailDialog({
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SkillFilePreviewDialog
|
||||
skillName={skill?.name ?? null}
|
||||
path={openedFilePath}
|
||||
treeEditable={!!filesData?.editable}
|
||||
open={!!openedFilePath}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenedFilePath(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
@@ -38,6 +39,21 @@ export function WorkspaceNavChatList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
|
||||
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
|
||||
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
|
||||
// 这里显式拦截:同路径时用 replace + 强制生成新 location.key 的方式重置。
|
||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigate("/page/workspace/chats/new", {
|
||||
replace: true,
|
||||
state: { resetAt: Date.now() },
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||
@@ -49,7 +65,7 @@ export function WorkspaceNavChatList({
|
||||
asChild
|
||||
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new">
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
@@ -141,7 +157,7 @@ export function WorkspaceNavChatList({
|
||||
>
|
||||
<Link href="/page/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
<span>定时任务</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BotIcon,
|
||||
BrainIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
@@ -143,6 +146,16 @@ export function WorkspaceNavMenu() {
|
||||
问答前缀配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/private-square")
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
私密广场配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
@@ -171,6 +184,22 @@ export function WorkspaceNavMenu() {
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||
>
|
||||
<BotIcon />
|
||||
智能体管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -7,6 +7,8 @@ import React, {
|
||||
} from 'react';
|
||||
import { useTheme as useNextTheme } from 'next-themes';
|
||||
|
||||
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light';
|
||||
|
||||
@@ -83,6 +85,12 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
// Marker that the user has explicitly toggled the theme (via the appearance
|
||||
// settings page or any future toggle UI). When this flag is absent we treat
|
||||
// any value stored in ``strategy-theme`` as one we put there ourselves while
|
||||
// syncing the admin's default — so we'll re-sync on every boot, picking up
|
||||
// admin changes. When the flag is present we never override the user.
|
||||
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
|
||||
|
||||
function readPersistedTheme(): ThemeType {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -92,6 +100,16 @@ function readPersistedTheme(): ThemeType {
|
||||
return saved === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function hasExplicitChoice(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem(EXPLICIT_FLAG_KEY) === '1';
|
||||
}
|
||||
|
||||
function markExplicitChoice(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(EXPLICIT_FLAG_KEY, '1');
|
||||
}
|
||||
|
||||
// 主题上下文
|
||||
interface ThemeContextType {
|
||||
theme: ThemeType;
|
||||
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Admin-configured global default. If the user hasn't explicitly chosen
|
||||
// a theme yet, this value drives the rendered theme — and re-syncs on
|
||||
// every mount so an admin change propagates without users clearing
|
||||
// localStorage by hand.
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
|
||||
// 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
|
||||
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
|
||||
// 「用户已选过」,admin 默认就再也注入不进来了。
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
if (current !== 'light' && current !== 'dark') {
|
||||
const fallback =
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'light';
|
||||
setNextTheme(fallback);
|
||||
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
setNextTheme(legacy);
|
||||
if (current && current !== 'light' && current !== 'dark') {
|
||||
// Garbage value — wipe it so the boot-sync below can take over.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
// Migrate the legacy key as the user's explicit choice (it was set
|
||||
// long enough ago that we should preserve their pick rather than
|
||||
// override with admin default).
|
||||
setNextTheme(legacy);
|
||||
markExplicitChoice();
|
||||
}
|
||||
|
||||
if (legacy) {
|
||||
localStorage.removeItem('theme');
|
||||
}
|
||||
}, [mounted, setNextTheme]);
|
||||
|
||||
// Boot-time sync: when the user has never picked a theme themselves,
|
||||
// apply whatever the admin configured. Re-runs whenever the admin
|
||||
// setting changes (so a logged-in user sees the new default after
|
||||
// refreshing without having to clear cookies).
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (hasExplicitChoice()) return;
|
||||
const adminDefault = appearance?.default_theme;
|
||||
if (!adminDefault) return;
|
||||
if (resolvedTheme !== adminDefault) {
|
||||
setNextTheme(adminDefault);
|
||||
}
|
||||
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
||||
|
||||
const theme: ThemeType = mounted
|
||||
? resolvedTheme === 'light'
|
||||
? 'light'
|
||||
: 'dark'
|
||||
? resolvedTheme === 'dark'
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
markExplicitChoice();
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setNextTheme(newTheme);
|
||||
markExplicitChoice();
|
||||
};
|
||||
|
||||
const themeConfig = themes[theme];
|
||||
|
||||
@@ -38,6 +38,16 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function listAllAgentsAdmin(): Promise<Agent[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function getAgent(id: string): Promise<Agent> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
||||
if (!res.ok) throw new Error(`Agent '${id}' not found`);
|
||||
@@ -103,6 +113,46 @@ export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPrivateAgents(password: string): Promise<Agent[]> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/private-square/list`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(
|
||||
err.detail ?? `Failed to load private agents: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function setAgentPrivate(
|
||||
id: string,
|
||||
isPrivate: boolean,
|
||||
): Promise<Agent> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/${id}/private`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_private: isPrivate }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(
|
||||
err.detail ?? `Failed to update agent privacy: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export async function setAgentFeatured(
|
||||
id: string,
|
||||
order: number | null,
|
||||
@@ -124,6 +174,31 @@ export async function setAgentFeatured(
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export interface AgentSkillsSyncRemoval {
|
||||
agent_id: string;
|
||||
agent_name: string | null;
|
||||
user_id: string | null;
|
||||
removed_skills: string[];
|
||||
}
|
||||
|
||||
export interface AgentSkillsSyncResult {
|
||||
checked: number;
|
||||
modified: number;
|
||||
removals: AgentSkillsSyncRemoval[];
|
||||
}
|
||||
|
||||
export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/admin/sync-skills`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to sync agent skills: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<AgentSkillsSyncResult>;
|
||||
}
|
||||
|
||||
export async function checkAgentName(
|
||||
name: string,
|
||||
): Promise<{ available: boolean; name: string }> {
|
||||
|
||||
@@ -6,16 +6,38 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listAllAgentsAdmin,
|
||||
listPrivateAgents,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
/** Patch an updated agent into every cached agent listing immediately so
|
||||
* cards re-render before the background refetch finishes. Without this the
|
||||
* mutation just invalidates the query and the user has to wait for the
|
||||
* round-trip + render — which on a big list feels like nothing happened.
|
||||
*/
|
||||
function applyAgentToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
next: Agent,
|
||||
) {
|
||||
queryClient.setQueriesData<Agent[] | undefined>(
|
||||
{ queryKey: ["agents"] },
|
||||
(old) => {
|
||||
if (!Array.isArray(old)) return old;
|
||||
return old.map((a) => (a.id === next.id ? { ...a, ...next } : a));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
@@ -28,6 +50,21 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only listing — returns every agent including unpublished agents owned
|
||||
* by other users and private-square entries. Caller must be ``system_role
|
||||
* === "admin"`` or the backend will 403.
|
||||
*/
|
||||
export function useAllAgentsAdmin(enabled: boolean) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "admin", "all"],
|
||||
queryFn: () => listAllAgentsAdmin(),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useAgent(id: string | null | undefined) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
@@ -57,7 +94,8 @@ export function useUpdateAgent() {
|
||||
id: string;
|
||||
request: UpdateAgentRequest;
|
||||
}) => updateAgent(id, request),
|
||||
onSuccess: (_data, { id }) => {
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
@@ -96,7 +134,8 @@ export function useSetAgentFeatured() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
||||
setAgentFeatured(id, order),
|
||||
onSuccess: (_data, { id }) => {
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
@@ -110,3 +149,47 @@ export function useReorderFeaturedAgents() {
|
||||
onSuccess: () => invalidateAgentQueries(queryClient),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the password-gated private square. ``password`` is sent on every
|
||||
* fetch — wire ``enabled`` to false until the user has typed it so we don't
|
||||
* hit the endpoint with an empty body and get a 403.
|
||||
*/
|
||||
export function usePrivateAgents(password: string | null) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["agents", "private-square", password ?? ""],
|
||||
queryFn: () => listPrivateAgents(password!),
|
||||
enabled: !!password,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error, refetch };
|
||||
}
|
||||
|
||||
export function useSetAgentPrivate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
|
||||
setAgentPrivate(id, isPrivate),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["agents", "private-square"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminSyncAgentSkills() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: adminSyncAgentSkills,
|
||||
onSuccess: (result) => {
|
||||
if (result.modified > 0) {
|
||||
invalidateAgentQueries(queryClient);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface Agent {
|
||||
description: string;
|
||||
user_id: string | null;
|
||||
published: boolean;
|
||||
is_private: boolean;
|
||||
builtin: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
@@ -26,6 +27,7 @@ export interface CreateAgentRequest {
|
||||
description?: string;
|
||||
soul?: string;
|
||||
published?: boolean;
|
||||
is_private?: boolean;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
@@ -36,6 +38,7 @@ export interface UpdateAgentRequest {
|
||||
description?: string | null;
|
||||
soul?: string | null;
|
||||
published?: boolean | null;
|
||||
is_private?: boolean | null;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
function readUserId(): string | null {
|
||||
return getStoredAuth()?.user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
||||
* cross-tab changes — when another tab logs in as a different user, this tab
|
||||
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
||||
* which then collides with the new token and surfaces as a 404 on "新对话".
|
||||
* Reloading the tab is the simplest way to resync React state with the new
|
||||
* identity.
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||
|
||||
useEffect(() => {
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
const nextUserId = readUserId();
|
||||
if (nextUserId === lastUserIdRef.current) {
|
||||
return;
|
||||
}
|
||||
lastUserIdRef.current = nextUserId;
|
||||
if (nextUserId === null) {
|
||||
window.location.replace("#/login");
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -501,6 +501,20 @@ export const enUS: Translations = {
|
||||
detailAdminOnlyHint:
|
||||
"Only an admin or the skill publisher can edit the details.",
|
||||
contentSectionTitle: "SKILL.md source",
|
||||
filesSectionTitle: "Files",
|
||||
filesEmpty: "This skill has no files to display.",
|
||||
filesLoadError: "Failed to load files",
|
||||
filesPreviewBinary: "Binary file cannot be previewed inline — use Download.",
|
||||
filesPreviewUnsupported: "This file type is not supported for inline preview.",
|
||||
filesPreviewTooLarge: "File is too large for inline preview.",
|
||||
filesEditAction: "Edit",
|
||||
filesSaveAction: "Save",
|
||||
filesCancelEditAction: "Cancel",
|
||||
filesDownloadAction: "Download",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesReadOnlyHint: "Built-in skills are read-only.",
|
||||
viewDetailAction: "View skill details",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
|
||||
@@ -432,6 +432,20 @@ export interface Translations {
|
||||
detailSaveSuccess: string;
|
||||
detailAdminOnlyHint: string;
|
||||
contentSectionTitle: string;
|
||||
filesSectionTitle: string;
|
||||
filesEmpty: string;
|
||||
filesLoadError: string;
|
||||
filesPreviewBinary: string;
|
||||
filesPreviewUnsupported: string;
|
||||
filesPreviewTooLarge: string;
|
||||
filesEditAction: string;
|
||||
filesSaveAction: string;
|
||||
filesCancelEditAction: string;
|
||||
filesDownloadAction: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
|
||||
@@ -479,6 +479,20 @@ export const zhCN: Translations = {
|
||||
detailSaveSuccess: "详情已更新",
|
||||
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
|
||||
contentSectionTitle: "SKILL.md 原文",
|
||||
filesSectionTitle: "文件",
|
||||
filesEmpty: "该技能目录下没有可显示的文件。",
|
||||
filesLoadError: "加载文件失败",
|
||||
filesPreviewBinary: "二进制文件无法预览,可点击下载查看。",
|
||||
filesPreviewUnsupported: "该文件类型暂不支持预览。",
|
||||
filesPreviewTooLarge: "文件过大,无法在线预览。",
|
||||
filesEditAction: "编辑",
|
||||
filesSaveAction: "保存",
|
||||
filesCancelEditAction: "取消",
|
||||
filesDownloadAction: "下载",
|
||||
filesCopyPathAction: "复制路径",
|
||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
*/
|
||||
|
||||
export type SearchResultItem = {
|
||||
id?: string;
|
||||
recUuid?: string;
|
||||
uuid?: string;
|
||||
docId?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
@@ -300,6 +304,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
const date =
|
||||
pickFromLayers(layers, DATE_ALIASES) ??
|
||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
||||
const id = pickFromLayers(layers, ["id", "taskId", "recordId", "record_id"]);
|
||||
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
|
||||
const uuid = pickFromLayers(layers, ["uuid"]);
|
||||
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, date]) {
|
||||
@@ -324,6 +332,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
recUuid,
|
||||
uuid,
|
||||
docId,
|
||||
title,
|
||||
url,
|
||||
snippet: content,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||
export type { LocalSettings } from "./local";
|
||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
|
||||
export type { LocalSettings } from "./local";
|
||||
@@ -4,9 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
@@ -15,8 +17,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
@@ -33,7 +36,17 @@ export interface LocalSettings {
|
||||
* the appearance settings page.
|
||||
*/
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
};
|
||||
/**
|
||||
* Global switch for skill result display adapters. When disabled, chat
|
||||
* messages render tool results with the legacy generic search UI only.
|
||||
*/
|
||||
ragModeEnabled: boolean;
|
||||
/**
|
||||
* Admin-maintained words or short phrases removed from reference snippets
|
||||
* and dialogs, one entry per line in the settings UI.
|
||||
*/
|
||||
citationCleanupWords: string[];
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
| "thread_id"
|
||||
@@ -78,10 +91,10 @@ export function getThreadModelName(threadId: string): string | undefined {
|
||||
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
|
||||
}
|
||||
|
||||
export function saveThreadModelName(
|
||||
threadId: string,
|
||||
modelName: string | undefined,
|
||||
) {
|
||||
export function saveThreadModelName(
|
||||
threadId: string,
|
||||
modelName: string | undefined,
|
||||
) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
@@ -90,8 +103,26 @@ export function saveThreadModelName(
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, modelName);
|
||||
}
|
||||
localStorage.setItem(key, modelName);
|
||||
}
|
||||
|
||||
function getThreadRagModeStorageKey(threadId: string): string {
|
||||
return `${THREAD_RAG_MODE_KEY_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
export function isThreadRagModeLocked(threadId: string): boolean {
|
||||
if (!isBrowser() || !threadId) {
|
||||
return false;
|
||||
}
|
||||
return localStorage.getItem(getThreadRagModeStorageKey(threadId)) === "true";
|
||||
}
|
||||
|
||||
export function lockThreadRagMode(threadId: string) {
|
||||
if (!isBrowser() || !threadId) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(getThreadRagModeStorageKey(threadId), "true");
|
||||
}
|
||||
|
||||
export function applyThreadModelOverride(
|
||||
settings: LocalSettings,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
|
||||
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||||
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||||
const MAX_DISPLAY_ITEMS = 100;
|
||||
|
||||
export interface DisplaySourceItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
score?: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
enabled: false,
|
||||
mode: "none",
|
||||
resultPath: "results",
|
||||
titleField: null,
|
||||
summaryField: null,
|
||||
contentField: null,
|
||||
sourceField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
urlField: null,
|
||||
idField: null,
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
});
|
||||
|
||||
const candidateFields: Record<string, string[]> = {
|
||||
titleField: ["title", "m_title", "name", "subject", "headline"],
|
||||
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||
typeField: ["type", "category", "source_category", "source1"],
|
||||
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||
urlField: ["url", "link", "href", "source_url"],
|
||||
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
||||
};
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
if (!path) return value;
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function findFirstArray(
|
||||
value: unknown,
|
||||
path = "",
|
||||
): { path: string; items: Record<string, unknown>[] } | null {
|
||||
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
||||
return { path, items: value as Record<string, unknown>[] };
|
||||
}
|
||||
if (!value || typeof value !== "object") return null;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||
const keys = Object.keys(sample);
|
||||
for (const candidate of candidates) {
|
||||
const exact = keys.find((key) => key === candidate);
|
||||
if (exact) return exact;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const lowered = candidate.toLowerCase();
|
||||
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
|
||||
if (fuzzy) return fuzzy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
||||
const sample = found.items[0] ?? {};
|
||||
const inferred = emptyDisplayConfig();
|
||||
inferred.enabled = true;
|
||||
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||
inferred.resultPath = found.path || "results";
|
||||
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
|
||||
|
||||
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||
}
|
||||
|
||||
if (skillName === "knowledge-search-v2") {
|
||||
inferred.titleField = inferred.titleField || "title";
|
||||
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||
inferred.contentField = inferred.contentField || "content_preview";
|
||||
inferred.sourceField = inferred.sourceField || "source";
|
||||
inferred.typeField = inferred.typeField || "source_category";
|
||||
inferred.timeField = inferred.timeField || "publish_time";
|
||||
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||
inferred.idField = pickField(sample, ["recUuid", "rec_uuid", "uuid", "id"]) || inferred.idField;
|
||||
inferred.linkStrategy = inferred.urlField ? "auto" : "id";
|
||||
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
|
||||
}
|
||||
|
||||
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function buildDefaultTableColumns(
|
||||
sample: Record<string, unknown>,
|
||||
config: SkillDisplayConfig,
|
||||
): NonNullable<SkillDisplayConfig["tableColumns"]> {
|
||||
const preferred = [
|
||||
config.titleField,
|
||||
config.typeField,
|
||||
config.sourceField,
|
||||
config.timeField,
|
||||
config.scoreField,
|
||||
].filter((field): field is string => !!field);
|
||||
const fallback = Object.keys(sample)
|
||||
.filter((field) => !preferred.includes(field))
|
||||
.slice(0, Math.max(0, 6 - preferred.length));
|
||||
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
|
||||
label: field,
|
||||
field,
|
||||
}));
|
||||
}
|
||||
|
||||
function stringify(value: unknown, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
||||
encodeURIComponent(stringify(item[key])),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
||||
const id = config.idField ? stringify(item[config.idField]) : "";
|
||||
if (config.linkStrategy === "url") return url || undefined;
|
||||
if (config.linkStrategy === "id") {
|
||||
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||
}
|
||||
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
|
||||
}
|
||||
|
||||
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||
const rawItems = getByPath(payload, config.resultPath);
|
||||
if (!Array.isArray(rawItems)) return [];
|
||||
const maxItems = resolveMaxItems(config, rawItems.length);
|
||||
return rawItems
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||
.slice(0, maxItems)
|
||||
.map((item, index) => ({
|
||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||
score:
|
||||
config.scoreField && typeof item[config.scoreField] === "number"
|
||||
? (item[config.scoreField] as number)
|
||||
: undefined,
|
||||
url: resolveLink(item, config),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
|
||||
function inferMaxItems(
|
||||
itemCount: number,
|
||||
mode: SkillDisplayConfig["mode"],
|
||||
): number {
|
||||
const fallback =
|
||||
mode === "citation" ? DEFAULT_CITATION_MAX_ITEMS : DEFAULT_LIST_MAX_ITEMS;
|
||||
return Math.min(
|
||||
MAX_DISPLAY_ITEMS,
|
||||
Math.max(fallback, itemCount || fallback),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMaxItems(config: SkillDisplayConfig, itemCount: number): number {
|
||||
const configured = Number(config.maxItems) || 0;
|
||||
if (config.mode === "citation" && configured <= 8 && itemCount > configured) {
|
||||
return Math.min(itemCount, MAX_DISPLAY_ITEMS);
|
||||
}
|
||||
return Math.min(
|
||||
MAX_DISPLAY_ITEMS,
|
||||
Math.max(1, configured || inferMaxItems(itemCount, config.mode)),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill, SkillState } from "./type";
|
||||
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
@@ -20,6 +20,17 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function loadAllSkillsAdmin() {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills?admin=true`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skills");
|
||||
}
|
||||
const json = await response.json();
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function getSkill(skillName: string): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||
@@ -46,6 +57,62 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function setSkillDisplay(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
sample?: Record<string, unknown> | null,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ display, sample }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PersistedSkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function getPersistedSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<PersistedSkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
||||
}
|
||||
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export async function getSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<SkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get skill display sample");
|
||||
}
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
@@ -239,6 +306,92 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkillFileNode {
|
||||
name: string;
|
||||
type: "file" | "dir";
|
||||
path: string;
|
||||
size?: number | null;
|
||||
mtime?: number | null;
|
||||
children?: SkillFileNode[] | null;
|
||||
}
|
||||
|
||||
export interface SkillFilesResponse {
|
||||
name: string;
|
||||
category: "public" | "custom";
|
||||
editable: boolean;
|
||||
root: SkillFileNode;
|
||||
}
|
||||
|
||||
export interface SkillFileContentResponse {
|
||||
name: string;
|
||||
category: "public" | "custom";
|
||||
path: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
is_text: boolean;
|
||||
editable: boolean;
|
||||
content?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillFileSaveResponse {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export async function getSkillFiles(skillName: string): Promise<SkillFilesResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill files");
|
||||
}
|
||||
return response.json() as Promise<SkillFilesResponse>;
|
||||
}
|
||||
|
||||
export async function getSkillFileContent(
|
||||
skillName: string,
|
||||
path: string,
|
||||
): Promise<SkillFileContentResponse> {
|
||||
const url =
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
|
||||
`?path=${encodeURIComponent(path)}`;
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to read skill file");
|
||||
}
|
||||
return response.json() as Promise<SkillFileContentResponse>;
|
||||
}
|
||||
|
||||
/** URL suitable for direct `<img>` / `<iframe>` / `<a download>` embedding. */
|
||||
export function getSkillFileDownloadURL(skillName: string, path: string): string {
|
||||
return (
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/download` +
|
||||
`?path=${encodeURIComponent(path)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<SkillFileSaveResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, content }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to save skill file");
|
||||
}
|
||||
return response.json() as Promise<SkillFileSaveResponse>;
|
||||
}
|
||||
|
||||
export interface SetSkillDetailResponse {
|
||||
name: string;
|
||||
detail: string | null;
|
||||
|
||||
@@ -10,17 +10,21 @@ import {
|
||||
enableSkill,
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
getSkillFileContent,
|
||||
getSkillFiles,
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { SkillState } from "./type";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
import { loadAllSkillsAdmin, loadSkills } from ".";
|
||||
|
||||
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
@@ -33,6 +37,21 @@ export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only listing — every skill in the system (built-in + every user's
|
||||
* custom skills, including unpublished ones owned by other users). Backend
|
||||
* 403s if the caller is not ``system_role === "admin"``.
|
||||
*/
|
||||
export function useAllSkillsAdmin(enabled: boolean) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", "admin", "all"],
|
||||
queryFn: () => loadAllSkillsAdmin(),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useSkillContent(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-content", skillName],
|
||||
@@ -61,6 +80,31 @@ export function useSetSkillDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillDisplay() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
display,
|
||||
sample,
|
||||
}: {
|
||||
skillName: string;
|
||||
display: Parameters<typeof setSkillDisplay>[1];
|
||||
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||
}) => setSkillDisplay(skillName, display, sample),
|
||||
onSuccess: (data, { skillName }) => {
|
||||
queryClient.setQueryData(["skill", skillName], data);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||
: current,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -118,6 +162,10 @@ export function useSetSkillPublished() {
|
||||
}) => setSkillPublished(skillName, published),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
// Unpublishing triggers the backend cascade that strips this skill
|
||||
// from every referencing agent's config.yaml, so the cached agent
|
||||
// list (agent.skills) is now stale.
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -150,6 +198,50 @@ export function useDeleteCustomSkill() {
|
||||
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
// Deletion triggers the backend cascade that strips this skill from
|
||||
// every referencing agent's config.yaml, so the cached agent list
|
||||
// (agent.skills) is now stale.
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillFiles(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-files", skillName],
|
||||
queryFn: () => getSkillFiles(skillName!),
|
||||
enabled: !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillFileContent(skillName: string | null, path: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-file", skillName, path],
|
||||
queryFn: () => getSkillFileContent(skillName!, path!),
|
||||
enabled: !!skillName && !!path,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
path,
|
||||
content,
|
||||
}: {
|
||||
skillName: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}) => saveSkillFile(skillName, path, content),
|
||||
onSuccess: (_data, { skillName, path }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-file", skillName, path] });
|
||||
// SKILL.md edits also affect the source-tab view and skill metadata.
|
||||
if (path === "SKILL.md") {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,36 @@ import type { Tag } from "@/core/tags";
|
||||
|
||||
export type SkillState = "active" | "stale" | "archived";
|
||||
export type SkillSource = "agent" | "upload";
|
||||
export type SkillDisplayMode = "citation" | "list" | "table" | "card" | "custom" | "none";
|
||||
export type SkillLinkStrategy = "auto" | "url" | "id";
|
||||
export type SkillCitationToken = "doubleBracket" | "singleBracket";
|
||||
|
||||
export interface SkillDisplayTableColumn {
|
||||
label: string;
|
||||
field: string;
|
||||
width?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillDisplayConfig {
|
||||
enabled: boolean;
|
||||
mode: SkillDisplayMode;
|
||||
resultPath: string;
|
||||
titleField?: string | null;
|
||||
summaryField?: string | null;
|
||||
contentField?: string | null;
|
||||
sourceField?: string | null;
|
||||
typeField?: string | null;
|
||||
timeField?: string | null;
|
||||
scoreField?: string | null;
|
||||
urlField?: string | null;
|
||||
idField?: string | null;
|
||||
linkStrategy: SkillLinkStrategy;
|
||||
linkTemplate?: string | null;
|
||||
citationToken: SkillCitationToken;
|
||||
maxItems: number;
|
||||
customInstruction: string;
|
||||
tableColumns?: SkillDisplayTableColumn[];
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
@@ -17,6 +47,8 @@ export interface Skill {
|
||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||
detail?: string | null;
|
||||
display?: SkillDisplayConfig | null;
|
||||
displaySample?: Record<string, unknown> | null;
|
||||
// Lifecycle fields — populated by curator. The main list filters on these
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
|
||||
@@ -42,3 +42,127 @@ export async function updatePromptPrefixSettings(
|
||||
}
|
||||
return (await response.json()) as PromptPrefixSettings;
|
||||
}
|
||||
|
||||
export interface PrivateSquareStatus {
|
||||
enabled: boolean;
|
||||
password_set: boolean;
|
||||
}
|
||||
|
||||
export interface PrivateSquareUpdate {
|
||||
enabled?: boolean;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CitationDisplaySettings {
|
||||
cleanup_words: string[];
|
||||
}
|
||||
|
||||
export interface CitationDisplayUpdate {
|
||||
cleanup_words?: string[];
|
||||
}
|
||||
|
||||
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load private square settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as PrivateSquareStatus;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export interface AppearanceSettings {
|
||||
default_theme: ThemeMode;
|
||||
}
|
||||
|
||||
export interface AppearanceUpdate {
|
||||
default_theme?: ThemeMode;
|
||||
}
|
||||
|
||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/appearance`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load appearance settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as AppearanceSettings;
|
||||
}
|
||||
|
||||
export async function updateAppearanceSettings(
|
||||
body: AppearanceUpdate,
|
||||
): Promise<AppearanceSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/appearance`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update appearance" }));
|
||||
throw new Error(detail.detail ?? "Failed to update appearance");
|
||||
}
|
||||
return (await response.json()) as AppearanceSettings;
|
||||
}
|
||||
|
||||
export async function loadCitationDisplaySettings(): Promise<CitationDisplaySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/citation-display`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load citation display settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as CitationDisplaySettings;
|
||||
}
|
||||
|
||||
export async function updateCitationDisplaySettings(
|
||||
body: CitationDisplayUpdate,
|
||||
): Promise<CitationDisplaySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/citation-display`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update citation display" }));
|
||||
throw new Error(detail.detail ?? "Failed to update citation display");
|
||||
}
|
||||
return (await response.json()) as CitationDisplaySettings;
|
||||
}
|
||||
|
||||
export async function updatePrivateSquareSettings(
|
||||
body: PrivateSquareUpdate,
|
||||
): Promise<PrivateSquareStatus> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update private square" }));
|
||||
throw new Error(detail.detail ?? "Failed to update private square");
|
||||
}
|
||||
return (await response.json()) as PrivateSquareStatus;
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
loadAppearanceSettings,
|
||||
loadCitationDisplaySettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
} from "./api";
|
||||
|
||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -27,3 +39,66 @@ export function useUpdatePromptPrefixSettings() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrivateSquareStatus() {
|
||||
return useQuery({
|
||||
queryKey: PRIVATE_SQUARE_KEY,
|
||||
queryFn: () => loadPrivateSquareStatus(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePrivateSquareSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: PrivateSquareUpdate) =>
|
||||
updatePrivateSquareSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(PRIVATE_SQUARE_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppearanceSettings() {
|
||||
return useQuery({
|
||||
queryKey: APPEARANCE_KEY,
|
||||
queryFn: () => loadAppearanceSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
// The provider runs even on the login page (where the user has no auth)
|
||||
// — gracefully tolerate the fetch failing rather than retrying.
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAppearanceSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: AppearanceUpdate) => updateAppearanceSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(APPEARANCE_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCitationDisplaySettings() {
|
||||
return useQuery({
|
||||
queryKey: CITATION_DISPLAY_KEY,
|
||||
queryFn: () => loadCitationDisplaySettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCitationDisplaySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CitationDisplayUpdate) =>
|
||||
updateCitationDisplaySettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(CITATION_DISPLAY_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,16 +3,19 @@ import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
import { getAPIClient } from "../api";
|
||||
import { apiFetch } from "../api";
|
||||
import { getStoredAuth } from "../auth";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -136,6 +139,42 @@ function getStreamErrorMessage(error: unknown): string {
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "thread not found" / forbidden errors from the LangGraph SDK.
|
||||
*
|
||||
* Backend returns 404 both when a thread truly doesn't exist and when the
|
||||
* currently authenticated user doesn't own it — the SDK surfaces both as
|
||||
* ``HTTP 404: {"detail":"Thread <uuid> not found"}``. The most common cause
|
||||
* we see in practice is a stale tab whose URL still points at the previous
|
||||
* login session's thread. Raw-toasting the 404 just confuses the user, so
|
||||
* we re-frame it around the current login identity.
|
||||
*/
|
||||
function isThreadNotAccessibleError(message: string): boolean {
|
||||
const lowered = message.toLowerCase();
|
||||
if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) {
|
||||
return true;
|
||||
}
|
||||
// Fallback: a bare 404/403 from the threads endpoint when the SDK has
|
||||
// wrapped the detail differently.
|
||||
return (
|
||||
(lowered.includes("http 404") || lowered.includes("http 403")) &&
|
||||
lowered.includes("thread")
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentUserLabel(): string {
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) {
|
||||
return "未登录";
|
||||
}
|
||||
const email = auth.email?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
const userId = auth.user_id?.trim();
|
||||
return userId ? userId : "未知用户";
|
||||
}
|
||||
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
context,
|
||||
@@ -146,6 +185,7 @@ export function useThreadStream({
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
@@ -193,6 +233,7 @@ export function useThreadStream({
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const navigate = useNavigate();
|
||||
const runMetadataStorageRef = useRef<
|
||||
ReturnType<typeof getRunMetadataStorage> | undefined
|
||||
>(undefined);
|
||||
@@ -292,7 +333,29 @@ export function useThreadStream({
|
||||
},
|
||||
onError(error) {
|
||||
setOptimisticMessages([]);
|
||||
toast.error(getStreamErrorMessage(error));
|
||||
const message = getStreamErrorMessage(error);
|
||||
// A "thread not found" 404 almost always means the URL points at a
|
||||
// thread from a previous login session — either this tab outlived a
|
||||
// logout/re-login or the thread genuinely belongs to a different user.
|
||||
// Re-frame the error around the *current* logged-in identity and bounce
|
||||
// the user to a fresh chat instead of leaving them stuck on the stale
|
||||
// route looking at a raw HTTP 404.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: "/page/workspace/chats/new";
|
||||
navigate(target, { replace: true });
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
onFinish(state) {
|
||||
listeners.current.onFinish?.(state.values);
|
||||
@@ -307,9 +370,37 @@ export function useThreadStream({
|
||||
// Track message count before sending so we know when server has responded
|
||||
const prevMsgCountRef = useRef(thread.messages.length);
|
||||
|
||||
// Reset thread-local pending UI state when switching between threads so
|
||||
// optimistic messages and in-flight guards do not leak across chat views.
|
||||
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
||||
// an in-flight stream without re-running on every render that produces a
|
||||
// new `thread` identity.
|
||||
const threadStreamRef = useRef(thread);
|
||||
threadStreamRef.current = thread;
|
||||
|
||||
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
|
||||
//
|
||||
// We deliberately do NOT reset on every threadId change. During a normal
|
||||
// "user just sent the first message on /chats/new" flow, useThreadStream
|
||||
// sees threadId transition from undefined (the landing state) to the
|
||||
// server-assigned thread id once onCreated fires. Stopping the stream and
|
||||
// clearing optimistic messages in that case is exactly what made submitted
|
||||
// questions visually disappear — the live run that just started would be
|
||||
// aborted by the very effect intended to reset stale state.
|
||||
useEffect(() => {
|
||||
if (threadId != null) {
|
||||
// Forward direction: starting or continuing with a real thread.
|
||||
// No reset needed; the upstream useStream will load history for this id.
|
||||
return;
|
||||
}
|
||||
const prevThread = threadStreamRef.current;
|
||||
if (prevThread?.isLoading) {
|
||||
try {
|
||||
prevThread.stop();
|
||||
} catch {
|
||||
// best-effort; nothing useful to do if the SDK fails to abort
|
||||
}
|
||||
}
|
||||
startedRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
prevMsgCountRef.current = 0;
|
||||
@@ -464,7 +555,13 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
await thread.submit(
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
@@ -507,9 +604,10 @@ export function useThreadStream({
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} catch (error) {
|
||||
@@ -520,7 +618,7 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
|
||||
);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
|
||||
@@ -18,8 +18,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
rag_mode_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,18 @@ interface Shortcut {
|
||||
export function useGlobalShortcuts(shortcuts: Shortcut[]) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// ``event.key`` is ``undefined`` for synthetic / IME-composition /
|
||||
// some extension-injected keydown events. Calling ``.toLowerCase()``
|
||||
// on it throws and kills every shortcut after this one — bail out
|
||||
// before that happens.
|
||||
if (typeof event.key !== "string") return;
|
||||
const meta = event.metaKey || event.ctrlKey;
|
||||
const pressed = event.key.toLowerCase();
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
if (typeof shortcut.key !== "string") continue;
|
||||
if (
|
||||
event.key.toLowerCase() === shortcut.key.toLowerCase() &&
|
||||
pressed === shortcut.key.toLowerCase() &&
|
||||
meta === shortcut.meta &&
|
||||
(shortcut.shift ?? false) === event.shiftKey
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { AgentCard } from "@/components/workspace/agents/agent-card";
|
||||
import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents";
|
||||
import type { AgentSkillsSyncResult } from "@/core/agents/api";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
export default function AdminAgentsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { agents, isLoading } = useAllAgentsAdmin(true);
|
||||
const syncSkills = useAdminSyncAgentSkills();
|
||||
const [syncResult, setSyncResult] = useState<AgentSkillsSyncResult | null>(
|
||||
null,
|
||||
);
|
||||
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
|
||||
|
||||
async function handleSyncSkills() {
|
||||
try {
|
||||
const result = await syncSkills.mutateAsync();
|
||||
setSyncResult(result);
|
||||
setSyncDialogOpen(true);
|
||||
if (result.modified === 0) {
|
||||
toast.success("所有智能体的技能均有效,无需清理");
|
||||
} else {
|
||||
toast.success(
|
||||
`已清理 ${result.modified} 个智能体中的失效技能引用`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return agents;
|
||||
return agents.filter((a) =>
|
||||
desensitize(a.name).toLowerCase().includes(needle),
|
||||
);
|
||||
}, [agents, search]);
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">智能体管理</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSyncSkills}
|
||||
disabled={syncSkills.isPending}
|
||||
title="扫描所有智能体的 config.yaml,移除指向已不存在技能的引用"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`mr-1.5 size-4 ${syncSkills.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{syncSkills.isPending ? "正在同步…" : "同步技能"}
|
||||
</Button>
|
||||
<div className="relative w-64">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可管理的智能体。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
共 {filteredAgents.length} 个智能体。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
|
||||
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>同步技能 — 清理结果</DialogTitle>
|
||||
<DialogDescription>
|
||||
{syncResult
|
||||
? `扫描 ${syncResult.checked} 个智能体配置,清理 ${syncResult.modified} 个。`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{syncResult && syncResult.removals.length > 0 ? (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<ul className="space-y-3 text-sm">
|
||||
{syncResult.removals.map((entry) => (
|
||||
<li
|
||||
key={entry.agent_id}
|
||||
className="rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{entry.agent_name
|
||||
? desensitize(entry.agent_name)
|
||||
: entry.agent_id}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
id: {entry.agent_id}
|
||||
{entry.user_id ? ` · owner: ${entry.user_id}` : " · built-in"}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{entry.removed_skills.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="bg-muted rounded px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
所有智能体引用的技能均存在,未做任何修改。
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSyncDialogOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function AdminSkillsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { skills, isLoading, error } = useAllSkillsAdmin(true);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
|
||||
// Hide archived/stale entries here — they remain accessible from the regular
|
||||
// skills settings dialog. The admin page is for live skills only.
|
||||
const filteredSkills = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return skills
|
||||
.filter((s) => (s.state ?? "active") === "active")
|
||||
.filter((s) => !needle || desensitize(s.name).toLowerCase().includes(needle));
|
||||
}, [skills, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
const pagedSkills = useMemo(
|
||||
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[filteredSkills, page],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">技能管理</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-64 shrink-0">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-destructive text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<SparklesIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可管理的技能。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
共 {filteredSkills.length} 个技能。
|
||||
</p>
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -231,14 +231,25 @@ export default function ChatPage() {
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
{/*
|
||||
Landing state covers two cases: (a) a brand-new chat that
|
||||
hasn't sent anything yet, and (b) the user clicking 「新对话」
|
||||
mid-stream. In (b) the underlying useStream's internal
|
||||
stream.values takes one or two effect ticks to clear, so
|
||||
rendering MessageList unconditionally would leak the previous
|
||||
conversation under the welcome card. Skip rendering during
|
||||
the landing state to guarantee a clean slate.
|
||||
*/}
|
||||
{!isLandingState && (
|
||||
<MessageList
|
||||
className={cn("size-full", "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, ShieldIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
usePrivateSquareStatus,
|
||||
useUpdatePrivateSquareSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export default function PrivateSquareSettingsPage() {
|
||||
const { data, isLoading, error } = usePrivateSquareStatus();
|
||||
const update = useUpdatePrivateSquareSettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
// The status response never contains the password itself, so re-syncing
|
||||
// ``enabled`` from a refetch wouldn't clobber any in-progress edit — but we
|
||||
// still gate the seed on a one-shot ref to match the prompt-prefix page's
|
||||
// semantics: the admin's draft must not flicker back to the server value if
|
||||
// react-query refetches while they're typing.
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (password && password !== confirm) {
|
||||
toast.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
// Only send the password field when the admin typed something —
|
||||
// omitting it leaves the server-side hash untouched. The empty-string
|
||||
// sentinel that clears the password is sent via the explicit "清除
|
||||
// 密码" button below, not by leaving the field blank.
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPassword = async () => {
|
||||
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({ password: "" });
|
||||
toast.success("已清除密码");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "清除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">私密广场配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
|
||||
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">启用私密广场</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭后,「私密广场」页签不会显示;已放入私密广场的智能体也不会被列出。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">
|
||||
访问密码
|
||||
{data?.password_set ? (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
(已设置,留空则保持不变)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-destructive ml-2 text-xs font-normal">
|
||||
(尚未设置)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={
|
||||
data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">确认密码</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="再次输入新密码"
|
||||
disabled={!password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleClearPassword}
|
||||
disabled={!data?.password_set || update.isPending}
|
||||
>
|
||||
<ShieldIcon className="mr-1.5 size-4" />
|
||||
清除密码
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
@@ -129,6 +130,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PrivateSquareSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -18,12 +18,15 @@ import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AdminAgentsPage from "./AdminAgentsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminSkillsPage from "./AdminSkillsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
@@ -281,6 +284,30 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
@@ -317,6 +344,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PrivateSquareSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -567,6 +567,20 @@
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reference-flash {
|
||||
0% {
|
||||
background: rgba(254, 243, 199, 0.95);
|
||||
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.28);
|
||||
}
|
||||
70% {
|
||||
background: rgba(254, 243, 199, 0.58);
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.14);
|
||||
}
|
||||
100% {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
||||
@@ -818,4 +832,4 @@
|
||||
* {
|
||||
border-color: var(--border);
|
||||
outline-color: var(--ring);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user