This commit is contained in:
fengxiangdebaozi committed 2026-05-28 15:10:25 +08:00
commit d03f5fbbc0
79 files changed
+9502 -1647

No files matched your search

+13 -7
View File
@@ -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>
);
}
+9 -1
View File
@@ -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")}
+55 -11
View File
@@ -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];
+75
View File
@@ -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 }> {
+86 -3
View File
@@ -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);
}
},
});
}
+3
View File
@@ -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;
+47
View File
@@ -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,
+3 -2
View File
@@ -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";
+43 -12
View File
@@ -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
+154 -1
View File
@@ -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;
+94 -2
View File
@@ -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] });
}
},
});
}
+32
View File
@@ -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);
},
});
}
+107 -9
View File
@@ -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
+3 -2
View File
@@ -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
) {
+183
View File
@@ -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>
);
}
+140
View File
@@ -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>
);
}
+19 -8
View File
@@ -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>
);
}
+11
View File
@@ -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={
+15 -1
View File
@@ -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);
}
}