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

+4 -2
View File
@@ -5,8 +5,10 @@ frontend-web/.vscode/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
# Local runtime logs and pid files
.runtime/
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts
frontend-web/.env
+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);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
# host shell; values in this file are what the container will receive.
# AI provider keys. Replace placeholders and rotate any test key before release.
DEEPSEEK_API_KEY=sk-cea9133f82e24e9f939136746b05d5f8
DEEPSEEK_API_KEY=sk-9808fb2375534112ac275541aec14b9b
OPENAI_API_KEY=your-openai-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
@@ -11,16 +11,22 @@ from typing import Any
from uuid import uuid4
import yaml
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_agent_store, get_tag_store
from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_config, get_tag_store
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.cascade import sync_agent_skills_to_available
from deerflow.skills.storage import get_or_new_skill_storage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["agents"])
@@ -34,6 +40,7 @@ class AgentResponse(BaseModel):
description: str = Field(default="", description="Agent description")
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
published: bool = Field(default=False, description="Whether a user-created agent is public")
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
builtin: bool = Field(default=False, description="True when user_id is null")
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -70,6 +77,7 @@ class AgentCreateRequest(BaseModel):
description: str = Field(default="", description="Agent description")
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
published: bool = Field(default=False, description="Whether the new agent should be public")
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
@@ -82,6 +90,7 @@ class AgentUpdateRequest(BaseModel):
description: str | None = Field(default=None, description="Updated description")
soul: str | None = Field(default=None, description="Updated SOUL.md content")
published: bool | None = Field(default=None, description="Updated publication state")
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
@@ -321,6 +330,7 @@ def _record_to_response(
description=record.get("description") or "",
user_id=record.get("user_id"),
published=bool(record.get("published", False)),
is_private=bool(record.get("is_private", False)),
builtin=record.get("user_id") is None,
created_at=record.get("created_at"),
updated_at=record.get("updated_at"),
@@ -370,17 +380,21 @@ async def _get_editable_or_raise(
) -> tuple[dict[str, Any], str]:
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
caller is a system admin. Custom agents owned by other users still 403.
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
another user — so the admin console can manage the full agent catalog.
Non-admin callers are still restricted to agents they own.
"""
owned = await store.get_owned(agent_id, user_id)
if owned is not None:
return owned, "owner"
if is_admin:
record = await store.get_any(agent_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
return record, "admin"
visible = await store.get_visible(agent_id, user_id)
if visible is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
if is_admin and visible.get("user_id") is None:
return visible, "admin"
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
@@ -394,6 +408,14 @@ async def list_agents(
request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
admin: bool = Query(
default=False,
description=(
"Admin-only override. When ``true`` and the caller is a system admin, "
"every agent is returned (built-in + every user's agents + private-square "
"entries) regardless of the usual ownership/visibility filter."
),
),
) -> AgentsListResponse:
_require_agents_api_enabled()
@@ -402,7 +424,12 @@ async def list_agents(
tag_store: TagStore = get_tag_store(request)
await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request)
agents = await store.list_visible(user_id)
if admin:
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
agents = await store.list_all()
else:
agents = await store.list_visible(user_id)
# List endpoint stays lean: skip per-agent SOUL.md read, config.yaml
# parse, and dir self-heal — none of those feed UI fields the gallery /
# recommend dialog / selector actually render. Detail endpoints (single
@@ -542,6 +569,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
"description": request.description or "",
"user_id": _current_user_id(http_request),
"published": request.published,
"is_private": request.is_private,
}
)
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
@@ -582,6 +610,15 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
update_data["description"] = request.description or ""
if "published" in fields_set:
update_data["published"] = bool(request.published)
if "is_private" in fields_set:
update_data["is_private"] = bool(request.is_private)
# Mutual exclusion: an agent moved into the private square cannot
# also be publicly published. Force ``published=false`` whenever the
# same request sets ``is_private=true``, even if the client forgot
# to clear it. (When the request only sets one of the two, the
# other side is left untouched.)
if update_data.get("is_private") is True:
update_data["published"] = False
next_name = update_data.get("name", current["name"])
next_description = update_data.get("description", current.get("description") or "")
@@ -607,7 +644,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
if not update_data:
record = current
elif edit_mode == "admin":
record = await store.update_builtin(agent_id, update_data)
# Admins may edit built-in (``user_id IS NULL``) and any user-owned
# agent — ``update_any`` skips the ownership check that ``update``
# and ``update_builtin`` enforce.
record = await store.update_any(agent_id, update_data)
else:
record = await store.update(agent_id, user_id, update_data)
if record is None:
@@ -694,7 +734,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
)
if edit_mode == "admin":
deleted = await store.delete_builtin(agent_id)
deleted = await store.delete_any(agent_id)
else:
deleted = await store.delete(agent_id, user_id)
if not deleted:
@@ -758,6 +798,107 @@ async def set_agent_featured(
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
class PrivateSquareListRequest(BaseModel):
"""Request body for listing all private-square agents."""
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
class AgentPrivateUpdateRequest(BaseModel):
"""Admin-only request body for moving an agent into / out of the private square."""
is_private: bool = Field(..., description="Set true to move the agent into the private square, false to move it back into the public square.")
@router.post(
"/agents/private-square/list",
response_model=AgentsListResponse,
summary="List Private-Square Agents",
description=(
"Verify the admin-configured private-square password and return every "
"agent flagged ``is_private=true``. Returns 404 when no password is set, "
"403 when the supplied password does not match, and the full agent list "
"otherwise — regardless of ownership, since the password is the gate."
),
)
async def list_private_square(body: PrivateSquareListRequest, request: Request) -> AgentsListResponse:
_require_agents_api_enabled()
settings = load_system_settings().private_square
if not settings.password_hash:
raise HTTPException(status_code=404, detail="Private square password is not configured")
if not body.password or not await verify_password_async(body.password, settings.password_hash):
raise HTTPException(status_code=403, detail="Incorrect private square password")
try:
store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store)
records = await store.list_private()
responses = [_record_to_response(r, include_soul=True) for r in records]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.id, [])
return AgentsListResponse(agents=responses)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to list private agents: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list private agents: {str(e)}") from e
@router.put(
"/agents/{agent_id}/private",
response_model=AgentResponse,
summary="Move Agent Between Squares (admin)",
description=(
"Admin-only. Move any agent (built-in or user-owned) into or out of the "
"password-gated private square. Owners can already toggle the flag on "
"their own agents via the regular ``PUT /agents/{id}`` endpoint; this "
"endpoint exists so admins can re-categorize other users' agents too."
),
)
async def set_agent_private(
agent_id: str,
body: AgentPrivateUpdateRequest,
request: Request,
) -> AgentResponse:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can re-categorize other users' agents")
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
record = await store.get_any(agent_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
update: dict[str, Any] = {"is_private": bool(body.is_private)}
# Mutual exclusion — see ``update_agent`` above. Moving an agent into
# the private square also drops the public ``published`` flag so we
# never end up with an agent that's "in both squares".
if update["is_private"]:
update["published"] = False
if record.get("user_id") is None:
updated = await store.update_builtin(agent_id, update)
else:
updated = await store.update(agent_id, str(record["user_id"]), update)
if updated is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Admin set agent '%s' is_private=%s", agent_id, body.is_private)
return _record_to_response(updated, include_soul=True)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to set is_private for agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
class AgentFeaturedReorderRequest(BaseModel):
"""Request body for admin to bulk-reorder the homepage featured list."""
@@ -801,3 +942,78 @@ async def reorder_featured_agents(
except Exception as e:
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e
class AgentSkillRemovalEntry(BaseModel):
agent_id: str = Field(..., description="Agent storage id whose config.yaml was rewritten")
agent_name: str | None = Field(default=None, description="Display name from the DB, if any")
user_id: str | None = Field(default=None, description="Owner user id (null = built-in)")
removed_skills: list[str] = Field(..., description="Skill names that no longer exist on disk and were stripped")
class AgentSkillsSyncResponse(BaseModel):
checked: int = Field(..., description="Number of agent config.yaml files scanned")
modified: int = Field(..., description="Number of agents whose skills list was rewritten")
removals: list[AgentSkillRemovalEntry] = Field(default_factory=list, description="Per-agent removal details")
@router.post(
"/agents/admin/sync-skills",
response_model=AgentSkillsSyncResponse,
summary="Sync Agent Skills (admin)",
description=(
"Admin-only. Scan every agent's ``config.yaml`` and strip skill names that "
"no longer exist on disk. Repairs historical drift where a skill was "
"removed without the delete/unpublish cascade firing (e.g. deleted from "
"the filesystem manually, or removed in an older version of the code)."
),
)
async def admin_sync_agent_skills(
request: Request,
config: AppConfig = Depends(get_config),
) -> AgentSkillsSyncResponse:
_require_agents_api_enabled()
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can run agent-skills sync")
try:
storage = get_or_new_skill_storage(app_config=config)
all_skills = storage.load_skills(enabled_only=False)
available = {skill.name for skill in all_skills}
result = await asyncio.to_thread(sync_agent_skills_to_available, available)
# Enrich removals with display name + owner from the DB so the admin
# UI can show "我的智能体 X 移除了技能 Y" instead of just the id.
store = get_agent_store(request)
enriched: list[AgentSkillRemovalEntry] = []
for entry in result.get("removals", []):
agent_id = entry["agent_id"]
record = await store.get_any(agent_id)
enriched.append(
AgentSkillRemovalEntry(
agent_id=agent_id,
agent_name=(record.get("name") if record else None),
user_id=(record.get("user_id") if record else None),
removed_skills=entry["removed_skills"],
)
)
if result.get("modified"):
await refresh_skills_system_prompt_cache_async()
logger.info(
"Admin agent-skills sync: checked=%d modified=%d",
result["checked"],
result["modified"],
)
return AgentSkillsSyncResponse(
checked=result["checked"],
modified=result["modified"],
removals=enriched,
)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to sync agent skills: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to sync agent skills: {str(e)}") from e
@@ -182,10 +182,13 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request
current_config = get_extensions_config()
# Convert request to dict format for JSON serialization
config_data = {
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
}
config_data = {
"mcpServers": {name: server.model_dump(by_alias=True) for name, server in request.mcp_servers.items()},
"skills": {
name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items()
},
}
# Write the configuration to file
with open(config_path, "w", encoding="utf-8") as f:
File diff suppressed because it is too large. Load diff
@@ -3,10 +3,12 @@
from __future__ import annotations
import logging
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.system_settings import (
PromptPrefixSettings,
@@ -31,6 +33,30 @@ class PromptPrefixUpdateRequest(BaseModel):
prompt_prefix: str | None = None
class PrivateSquareStatusResponse(BaseModel):
"""Status of the private-square feature, readable by any logged-in user.
The password hash itself is **never** returned — the response only signals
whether the feature is enabled and whether a password has been set.
"""
enabled: bool = Field(..., description="Whether the private square is exposed to users")
password_set: bool = Field(..., description="Whether a password is currently configured")
class PrivateSquareUpdateRequest(BaseModel):
enabled: bool | None = Field(default=None, description="Toggle the private-square feature")
password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.")
class CitationDisplayResponse(BaseModel):
cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words")
class CitationDisplayUpdateRequest(BaseModel):
cleanup_words: list[str] | None = None
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
return PromptPrefixResponse(
enabled=settings.enabled,
@@ -61,5 +87,116 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
updated_prefix = current.prompt_prefix.model_copy(update=fields)
saved = save_system_settings(SystemSettings(prompt_prefix=updated_prefix))
saved = save_system_settings(current.model_copy(update={"prompt_prefix": updated_prefix}))
return _to_response(saved.prompt_prefix)
@router.get("/citation-display", response_model=CitationDisplayResponse)
async def get_citation_display() -> CitationDisplayResponse:
"""Return global citation display cleanup settings."""
settings = load_system_settings().citation_display
return CitationDisplayResponse(cleanup_words=settings.cleanup_words)
@router.put("/citation-display", response_model=CitationDisplayResponse)
async def update_citation_display(
body: CitationDisplayUpdateRequest,
request: Request,
) -> CitationDisplayResponse:
"""Update global citation display cleanup settings. Admin only."""
await _require_admin(request)
current = load_system_settings()
words = [
str(item).strip()
for item in (body.cleanup_words or [])
if str(item).strip()
]
updated = current.citation_display.model_copy(update={"cleanup_words": words})
saved = save_system_settings(
current.model_copy(update={"citation_display": updated})
)
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words)
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
async def get_private_square_status() -> PrivateSquareStatusResponse:
"""Return whether the private square is enabled and a password is set."""
settings = load_system_settings().private_square
return PrivateSquareStatusResponse(
enabled=settings.enabled,
password_set=bool(settings.password_hash),
)
@router.put("/private-square", response_model=PrivateSquareStatusResponse)
async def update_private_square(body: PrivateSquareUpdateRequest, request: Request) -> PrivateSquareStatusResponse:
"""Update the private-square configuration. Admin only.
Setting ``password`` to a non-empty string rehashes and stores it.
Passing an empty string clears the password (effectively locking the
private square so no one can read it).
"""
await _require_admin(request)
current = load_system_settings()
updated = current.private_square
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "enabled" in fields and fields["enabled"] is not None:
update["enabled"] = bool(fields["enabled"])
if "password" in fields and fields["password"] is not None:
plain = str(fields["password"])
if plain == "":
update["password_hash"] = ""
else:
update["password_hash"] = await hash_password_async(plain)
if update:
updated = updated.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"private_square": updated}))
else:
saved = current
return PrivateSquareStatusResponse(
enabled=saved.private_square.enabled,
password_set=bool(saved.private_square.password_hash),
)
class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults."""
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
class AppearanceUpdateRequest(BaseModel):
default_theme: Literal["light", "dark"] | None = Field(default=None)
@router.get("/appearance", response_model=AppearanceResponse)
async def get_appearance_settings() -> AppearanceResponse:
"""Return the global default theme. Any logged-in user may read this."""
settings = load_system_settings().appearance
return AppearanceResponse(default_theme=settings.default_theme)
@router.put("/appearance", response_model=AppearanceResponse)
async def update_appearance_settings(
body: AppearanceUpdateRequest, request: Request
) -> AppearanceResponse:
"""Update the global appearance defaults. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "default_theme" in fields and fields["default_theme"] is not None:
update["default_theme"] = fields["default_theme"]
if update:
updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings(
current.model_copy(update={"appearance": updated_appearance})
)
else:
saved = current
return AppearanceResponse(default_theme=saved.appearance.default_theme)
@@ -12,9 +12,12 @@ matching the LangGraph Platform wire format expected by the
from __future__ import annotations
import logging
import uuid
from typing import Any
import logging
import json
import os
from pathlib import Path
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint
@@ -23,13 +26,18 @@ from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer
from app.gateway.utils import sanitize_log_param
from deerflow.config.paths import Paths, get_paths
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso, now_iso
from deerflow.config.paths import Paths, get_paths
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.references import (
build_reference_batches as build_runtime_reference_batches,
build_reference_debug_summary,
)
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso, now_iso
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["threads"])
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["threads"])
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
# Metadata keys that the server controls; clients are not allowed to set
@@ -41,11 +49,38 @@ router = APIRouter(prefix="/api/threads", tags=["threads"])
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"})
def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
"""Return ``metadata`` with server-controlled keys removed."""
if not metadata:
return metadata or {}
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
"""Return ``metadata`` with server-controlled keys removed."""
if not metadata:
return metadata or {}
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
def _get_rag_debug_logger() -> logging.Logger:
debug_logger = logging.getLogger(_RAG_DEBUG_LOGGER_NAME)
if getattr(debug_logger, "_rag_file_configured", False):
return debug_logger
log_path = Path(os.environ.get("RAG_CITATION_DEBUG_LOG", "logs/rag_citation_debug.log"))
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.FileHandler(log_path, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
debug_logger.addHandler(handler)
debug_logger.setLevel(logging.INFO)
debug_logger.propagate = False
setattr(debug_logger, "_rag_file_configured", True)
setattr(debug_logger, "_rag_file_path", str(log_path))
return debug_logger
def _rag_debug_file_path() -> str:
return str(getattr(_get_rag_debug_logger(), "_rag_file_path", "logs/rag_citation_debug.log"))
def _log_rag_debug(message: str, *args: Any) -> None:
logger.info(message, *args)
_get_rag_debug_logger().info(message, *args)
# ---------------------------------------------------------------------------
@@ -132,11 +167,43 @@ class HistoryEntry(BaseModel):
next: list[str] = Field(default_factory=list)
class ThreadHistoryRequest(BaseModel):
class ThreadHistoryRequest(BaseModel):
"""Request body for checkpoint history."""
limit: int = Field(default=10, ge=1, le=100, description="Maximum entries")
before: str | None = Field(default=None, description="Cursor for pagination")
before: str | None = Field(default=None, description="Cursor for pagination")
class ReferenceSourceResponse(BaseModel):
index: int
id: str | None = None
skillName: str = "backend-reference"
mode: str = "citation"
title: str
type: str = "result"
snippet: str = ""
source: str | None = None
time: str | None = None
url: str | None = None
content: str | None = None
score: float | None = None
raw: dict[str, Any] = Field(default_factory=dict)
class ReferenceBatchResponse(BaseModel):
id: str
assistant_message_id: str
sources: list[ReferenceSourceResponse] = Field(default_factory=list)
class ReferenceBatchesResponse(BaseModel):
batches: list[ReferenceBatchResponse] = Field(default_factory=list)
class ReferenceDebugLogRequest(BaseModel):
event: str
thread_id: str | None = None
payload: dict[str, Any] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
@@ -163,7 +230,7 @@ def _delete_thread_data(thread_id: str, paths: Paths | None = None, *, user_id:
return ThreadDeleteResponse(success=True, message=f"Deleted local thread data for {thread_id}")
def _derive_thread_status(checkpoint_tuple) -> str:
def _derive_thread_status(checkpoint_tuple) -> str:
"""Derive thread status from checkpoint metadata."""
if checkpoint_tuple is None:
return "idle"
@@ -179,7 +246,251 @@ def _derive_thread_status(checkpoint_tuple) -> str:
if tasks:
return "interrupted"
return "idle"
return "idle"
def _message_type(message: Any) -> str:
if isinstance(message, dict):
return str(message.get("type") or message.get("role") or "")
return str(getattr(message, "type", "") or "")
def _message_id(message: Any) -> str | None:
if isinstance(message, dict):
value = message.get("id")
else:
value = getattr(message, "id", None)
return str(value) if value else None
def _message_content(message: Any) -> Any:
if isinstance(message, dict):
return message.get("content")
return getattr(message, "content", None)
def _message_name(message: Any) -> str:
if isinstance(message, dict):
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
return str(getattr(message, "name", None) or "tool")
def _has_tool_calls(message: Any) -> bool:
if isinstance(message, dict):
direct = message.get("tool_calls")
chunks = message.get("tool_call_chunks")
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
else:
direct = getattr(message, "tool_calls", None)
chunks = getattr(message, "tool_call_chunks", None)
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
return bool(direct or chunks or extra)
def _has_answer_content(message: Any) -> bool:
content = _message_content(message)
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text" and str(part.get("text") or "").strip():
return True
return False
def _extract_tool_payload(message: Any) -> Any:
content = _message_content(message)
if isinstance(content, dict):
return _unwrap_tool_payload(content)
if isinstance(content, list):
texts: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
if isinstance(part.get("json"), dict):
return _unwrap_tool_payload(part["json"])
if part.get("type") == "json" and isinstance(part.get("json"), dict):
return _unwrap_tool_payload(part["json"])
if part.get("type") == "text" and isinstance(part.get("text"), str):
texts.append(part["text"])
unwrapped = _unwrap_tool_payload(part)
if unwrapped is not None:
return unwrapped
content = "\n".join(texts)
if isinstance(content, str):
return _parse_json_like(content)
return None
def _unwrap_tool_payload(value: dict[str, Any]) -> Any:
if any(isinstance(value.get(key), list) for key in ("results", "data", "items", "hits", "documents", "records", "sources")):
return value
for key in ("result", "output", "stdout", "content", "text", "payload", "response"):
nested = value.get(key)
if isinstance(nested, str):
parsed = _parse_json_like(nested)
if parsed is not None:
return parsed
if isinstance(nested, dict):
unwrapped = _unwrap_tool_payload(nested)
if unwrapped is not None:
return unwrapped
return value
def _parse_json_like(text: str) -> Any:
trimmed = text.strip()
if not trimmed:
return None
try:
return json.loads(trimmed)
except Exception:
pass
first_obj = trimmed.find("{")
last_obj = trimmed.rfind("}")
if 0 <= first_obj < last_obj:
try:
return json.loads(trimmed[first_obj : last_obj + 1])
except Exception:
return None
return None
def _first_list(payload: Any) -> list[Any]:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ("results", "data", "items", "hits", "documents", "records", "entries", "matches", "chunks", "sources"):
value = payload.get(key)
if isinstance(value, list):
return value
for key in ("result", "payload", "response", "output", "data"):
nested = payload.get(key)
found = _first_list(nested)
if found:
return found
return []
def _nested_records(raw: Any) -> list[dict[str, Any]]:
if not isinstance(raw, dict):
return []
layers = [raw]
for key in ("metadata", "meta", "attrs", "attributes", "properties", "extra"):
nested = raw.get(key)
if isinstance(nested, dict):
layers.append(nested)
return layers
def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
for layer in layers:
for key in keys:
value = layer.get(key)
if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip()
return None
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
layers = _nested_records(raw)
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
score_raw = _pick(layers, ("score", "relevance", "similarity"))
score = None
if score_raw is not None:
try:
score = float(score_raw)
except Exception:
score = None
if not title:
title = source or f"结果 {fallback_index}"
if not content:
content = ""
if not title and not content and not url:
return None
return {
"id": item_id or url,
"title": title,
"type": "result",
"snippet": content,
"content": content,
"source": source,
"time": time,
"url": url,
"score": score,
"raw": raw,
}
def _reference_key(source: dict[str, Any]) -> str:
raw = source.get("raw") if isinstance(source.get("raw"), dict) else {}
item_id = (
source.get("id")
or source.get("url")
or raw.get("recUuid")
or raw.get("uuid")
or raw.get("docId")
or raw.get("id")
)
if item_id:
return str(item_id).casefold().strip()
return "|".join(
str(source.get(key) or "").casefold().strip()
for key in ("title", "source", "time", "content", "snippet")
)
def _build_reference_batches(messages: list[Any]) -> list[ReferenceBatchResponse]:
batches: list[ReferenceBatchResponse] = []
for index, message in enumerate(messages):
if _message_type(message) != "human":
continue
end = len(messages)
for cursor in range(index + 1, len(messages)):
if _message_type(messages[cursor]) == "human":
end = cursor
break
assistant_message: Any | None = None
for cursor in range(end - 1, index, -1):
candidate = messages[cursor]
if (
_message_type(candidate) == "ai"
and _message_id(candidate)
and _has_answer_content(candidate)
and not _has_tool_calls(candidate)
):
assistant_message = candidate
break
if assistant_message is None:
continue
seen: set[str] = set()
sources: list[ReferenceSourceResponse] = []
for tool_message in messages[index + 1 : end]:
if _message_type(tool_message) != "tool":
continue
payload = _extract_tool_payload(tool_message)
for raw_item in _first_list(payload):
normalized = _normalize_reference_item(raw_item, len(sources) + 1)
if not normalized:
continue
key = _reference_key(normalized)
if key and key in seen:
continue
if key:
seen.add(key)
sources.append(ReferenceSourceResponse(index=len(sources) + 1, **normalized))
if sources:
mid = _message_id(assistant_message) or str(index)
batches.append(ReferenceBatchResponse(id=mid, assistant_message_id=mid, sources=sources))
return batches
# ---------------------------------------------------------------------------
@@ -406,9 +717,9 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse:
# ---------------------------------------------------------------------------
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse:
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse:
"""Get the latest state snapshot for a thread.
Channel values are serialized to ensure LangChain message objects
@@ -455,12 +766,84 @@ async def get_thread_state(thread_id: str, request: Request) -> ThreadStateRespo
parent_checkpoint_id=parent_checkpoint_id,
created_at=coerce_iso(metadata.get("created_at", "")),
tasks=tasks,
)
@router.post("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
)
@router.get("/{thread_id}/reference-batches", response_model=ReferenceBatchesResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread_reference_batches(thread_id: str, request: Request) -> ReferenceBatchesResponse:
"""Return backend-built citation batches for completed assistant turns."""
checkpointer = get_checkpointer(request)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to get reference batches for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get reference batches")
if checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
messages = values.get("messages")
if not isinstance(messages, list):
_log_rag_debug(
"backend reference-batches thread=%s messages=missing batches=0 log_file=%s",
sanitize_log_param(thread_id),
_rag_debug_file_path(),
)
return ReferenceBatchesResponse(batches=[])
raw_batches = build_runtime_reference_batches(messages)
debug_summary = build_reference_debug_summary(messages, raw_batches)
_log_rag_debug(
"backend reference-batches thread=%s messages=%s turns=%s tool_messages=%s raw_items=%s "
"normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s turns_detail=%s log_file=%s",
sanitize_log_param(thread_id),
debug_summary["messages"],
debug_summary["turns"],
debug_summary["tool_messages"],
debug_summary["raw_items"],
debug_summary["normalized_items"],
debug_summary["kept_items"],
len(raw_batches),
",".join(str(batch.get("source_count", 0)) for batch in debug_summary["batches"]),
",".join(str(batch.get("id") or "") for batch in debug_summary["batches"]),
json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
_rag_debug_file_path(),
)
return ReferenceBatchesResponse(
batches=[
ReferenceBatchResponse(
id=str(batch.get("id") or ""),
assistant_message_id=str(batch.get("assistant_message_id") or batch.get("id") or ""),
sources=[
ReferenceSourceResponse(**source)
for source in batch.get("sources", [])
if isinstance(source, dict)
],
)
for batch in raw_batches
]
)
@router.post("/reference-debug-log")
async def write_reference_debug_log(body: ReferenceDebugLogRequest) -> dict[str, str | bool]:
"""Append frontend citation diagnostics to the backend RAG debug log."""
_get_rag_debug_logger().info(
"frontend %s thread=%s payload=%s log_file=%s",
body.event,
sanitize_log_param(body.thread_id or ""),
json.dumps(body.payload, ensure_ascii=False, default=str),
_rag_debug_file_path(),
)
return {"ok": True, "log_file": _rag_debug_file_path()}
@router.post("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
"""Update thread state (e.g. for human-in-the-loop resume or title rename).
Writes a new checkpoint that merges *body.values* into the latest
@@ -100,7 +100,14 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
# Built-in LangGraph graph IDs registered in langgraph.json.
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
_RAG_CITATION_PROMPT = """当前对话已开启参考文献模式。
如果本轮调用了搜索、知识库、检索类技能或工具,请基于本轮所有检索结果整体回答。
你必须把同一轮内多次检索得到的资料视为同一批参考文献,按出现顺序从 [1] 开始连续编号,不要让每个检索词各自从 [1] 重新编号。
回答正文中的关键结论必须标注对应编号,例如 [1]、[2],多个来源可写为 [1][3][5]。
不要引用不存在的编号,不要使用技能内部自带的编号,不要把参考文献完整列表堆在正文末尾。
如果没有任何可用检索结果,请直接说明未检索到可靠参考资料。"""
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
@@ -129,7 +136,7 @@ def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str
return {**graph_input, "messages": new_messages}
def _prompt_prefix_opted_in(body: Any) -> bool:
def _prompt_prefix_opted_in(body: Any) -> bool:
"""Detect whether the caller asked for the prompt prefix to be applied."""
context = getattr(body, "context", None)
if isinstance(context, Mapping) and context.get("prompt_prefix_enabled"):
@@ -137,7 +144,44 @@ def _prompt_prefix_opted_in(body: Any) -> bool:
metadata = getattr(body, "metadata", None)
if isinstance(metadata, Mapping) and metadata.get("prompt_prefix_enabled"):
return True
return False
return False
def _rag_mode_requested(body: Any) -> bool:
context = getattr(body, "context", None)
if isinstance(context, Mapping) and context.get("rag_mode_enabled"):
return True
metadata = getattr(body, "metadata", None)
if isinstance(metadata, Mapping) and (
metadata.get("rag_mode_enabled") or metadata.get("rag_mode")
):
return True
return False
def _thread_rag_mode_enabled(record: Any) -> bool:
if not isinstance(record, Mapping):
return False
metadata = record.get("metadata")
return isinstance(metadata, Mapping) and bool(
metadata.get("rag_mode_enabled") or metadata.get("rag_mode")
)
def _append_invisible_prompt(graph_input: dict[str, Any], prompt: str) -> dict[str, Any]:
messages = graph_input.get("messages")
if not isinstance(messages, list) or not messages:
return graph_input
new_messages = list(messages)
for idx, msg in enumerate(new_messages):
if not isinstance(msg, HumanMessage):
continue
extra = dict(getattr(msg, "additional_kwargs", None) or {})
existing = str(extra.get("prompt_prefix") or "").strip()
extra["prompt_prefix"] = f"{existing}\n\n{prompt}".strip() if existing else prompt
new_messages[idx] = msg.model_copy(update={"additional_kwargs": extra})
break
return {**graph_input, "messages": new_messages}
# Whitelist of run-context keys that the langgraph-compat layer forwards from
@@ -152,8 +196,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"writing_mode",
"reasoning_effort",
"rag_mode_enabled",
"writing_mode",
"writing_artifact_path",
"canvas_artifact_title",
"canvas_artifact_type",
@@ -398,16 +443,29 @@ async def start_run(
# Upsert thread metadata so the thread appears in /threads/search,
# even for threads that were never explicitly created via POST /threads
# (e.g. stateless runs).
try:
existing = await run_ctx.thread_store.get(thread_id)
if existing is None:
await run_ctx.thread_store.create(
thread_id,
assistant_id=body.assistant_id,
metadata=body.metadata,
)
else:
await run_ctx.thread_store.update_status(thread_id, "running")
requested_rag_mode = _rag_mode_requested(body)
effective_rag_mode = requested_rag_mode
try:
existing = await run_ctx.thread_store.get(thread_id)
effective_rag_mode = requested_rag_mode or _thread_rag_mode_enabled(existing)
if existing is None:
metadata = dict(body.metadata or {})
if effective_rag_mode:
metadata["rag_mode_enabled"] = True
metadata["rag_mode"] = True
await run_ctx.thread_store.create(
thread_id,
assistant_id=body.assistant_id,
metadata=metadata,
)
else:
await run_ctx.thread_store.update_status(thread_id, "running")
if requested_rag_mode and not _thread_rag_mode_enabled(existing):
await run_ctx.thread_store.update_metadata(
thread_id,
{"rag_mode_enabled": True, "rag_mode": True},
)
effective_rag_mode = True
# SDK 的 ``client.threads.create()`` 不传 assistant_id —— 先建出来空的 thread,
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
# assistant 筛选时拿不到关联。
@@ -427,17 +485,21 @@ async def start_run(
# applies once. We intentionally do NOT gate on thread-row existence: the
# thread_meta row is frequently pre-created, which previously skipped the
# prefix for genuine first turns.
if _prompt_prefix_opted_in(body):
prefix_settings = load_system_settings().prompt_prefix
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
if _prompt_prefix_opted_in(body):
prefix_settings = load_system_settings().prompt_prefix
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
if effective_rag_mode:
graph_input = _append_invisible_prompt(graph_input, _RAG_CITATION_PROMPT)
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
body_context = dict(getattr(body, "context", None) or {})
body_context = dict(getattr(body, "context", None) or {})
if effective_rag_mode:
body_context["rag_mode_enabled"] = True
# Promote whitelisted top-level body fields into the context map so
# callers can pass them either nested under ``context`` or as flat
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
+6 -6
View File
@@ -10,12 +10,12 @@ gateway:
port: 8001
models:
- name: deepseek-chat
display_name: DeepSeek Chat
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-1520028668cb4cc388ed8315ff1e856e
base_url: https://api.deepseek.com
model: zai-org/GLM-5-FP8
api_key: Bearer modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
base_url: https://api.us-west-2.modal.direct/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
@@ -121,7 +121,7 @@ sandbox:
allow_host_bash: true
skills:
path: D:\project\bj\deerflow-server\skills
path: D:\cursor-work\offline-backend-20260512\backend\skills
security_scan:
enabled: false
@@ -9,6 +9,7 @@ from deerflow.agents.middlewares.clarification_middleware import ClarificationMi
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
@@ -254,11 +255,14 @@ def _build_middlewares(
List of middleware instances.
"""
resolved_app_config = app_config or get_app_config()
cfg = _get_runtime_config(config)
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
# Inject the admin prompt prefix into the model request (kept out of the
# persisted/displayed message so the user never sees it).
middlewares.append(PromptPrefixMiddleware())
if cfg.get("rag_mode_enabled", False):
middlewares.append(RagCitationMiddleware())
# Add summarization middleware if enabled
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
@@ -266,7 +270,6 @@ def _build_middlewares(
middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled
cfg = _get_runtime_config(config)
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
@@ -371,6 +374,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
@@ -461,6 +465,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
is_bootstrap=True,
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
),
state_schema=ThreadState,
)
@@ -495,6 +500,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=(set(agent_config.skills or []) if agent_config else None),
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids,
),
@@ -667,17 +667,25 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
@lru_cache(maxsize=32)
def _get_cached_skills_prompt_section(
skill_signature: tuple[tuple[str, str, str, str], ...],
display_signature: tuple[tuple[str, str, str], ...],
available_skills_key: tuple[str, ...] | None,
container_base_path: str,
skill_evolution_section: str,
) -> str:
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
skills_list = ""
if filtered:
skill_items = "\n".join(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>\n </skill>"
for name, description, category, location in filtered
)
items = []
for name, description, category, location in filtered:
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
display_hint = ""
if display_mode:
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
items.append(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
)
skill_items = "\n".join(items)
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
# Restricted mode: agent has an explicit skill allowlist.
@@ -693,6 +701,19 @@ def _get_cached_skills_prompt_section(
else:
scope_note = ""
display_pattern = (
"""
**Skill Result Display Pattern:**
- If a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[[1]]`, the second as `[[2]]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
)
return f"""<skill_system>
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
{scope_note}
@@ -702,13 +723,19 @@ You have access to skills that provide optimized workflows for specific tasks. E
3. The skill file contains references to external resources under the same folder
4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely
{display_pattern}
{location_hint}{evolution_block}
{skills_list}
</skill_system>"""
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None) -> str:
def get_skills_prompt_section(
available_skills: set[str] | None = None,
*,
app_config: AppConfig | None = None,
display_enabled: bool = False,
) -> str:
"""Generate the skills prompt section with available skills list."""
skills = _get_enabled_skills_for_config(app_config)
@@ -739,11 +766,30 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c
return ""
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
display_signature: tuple[tuple[str, str, str], ...] = ()
if display_enabled:
try:
from deerflow.config.extensions_config import get_extensions_config
extensions = get_extensions_config()
display_signature = tuple(
(
name,
skill_config.display.mode,
skill_config.display.custom_instruction,
)
for name, skill_config in sorted(extensions.skills.items())
if skill_config.display is not None
and skill_config.display.enabled
and skill_config.display.mode != "none"
)
except Exception:
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
if not skill_signature and available_key is not None:
return ""
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
return _get_cached_skills_prompt_section(skill_signature, available_key, container_base_path, skill_evolution_section)
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
def get_agent_soul(agent_id: str | None) -> str:
@@ -868,6 +914,7 @@ def apply_prompt_template(
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False,
is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None,
) -> str:
@@ -902,7 +949,11 @@ def apply_prompt_template(
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
# Get skills section
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
# Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
@@ -940,6 +991,21 @@ Writing mode is enabled for this run.
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If the skill has a display adapter, follow that adapter first.
- For `citation`, cite returned items with `[[1]]`, `[[2]]`, etc. Only cite real returned items.
- For `table`, do not hand-write the table. Summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the table should appear. The frontend will render the real tool result as a stable table.
- For `list` or `card`, summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="list" />` or `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the structured result should appear.
- If no display adapter exists, default to citation mode with numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
# Format the prompt with dynamic skills and memory
prompt = SYSTEM_PROMPT_TEMPLATE.format(
@@ -957,7 +1023,7 @@ Writing mode is enabled for this run.
acp_section=acp_and_mounts_section,
)
prompt = prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
if is_notebook_mode:
source_ids = notebook_source_ids or []
@@ -61,6 +61,21 @@ _AUTH_PATTERNS = (
"无权",
"未授权",
)
# Upstream model gateway returned a malformed payload (typically a JSON decode
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
# response). These are almost always transient inference-side issues and safe
# to retry.
_UPSTREAM_GARBAGE_PATTERNS = (
"bad_response_status_code",
"bad response status",
"expecting ',' delimiter",
"expecting value",
"expecting property name",
"unexpected character",
"unterminated string",
"extra data",
"jsondecodeerror",
)
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
@@ -139,9 +154,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
detail = _extract_error_detail(exc)
lowered = detail.lower()
error_code = _extract_error_code(exc)
error_code_lower = str(error_code).lower() if error_code is not None else ""
status_code = _extract_status_code(exc)
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(str(error_code).lower(), _QUOTA_PATTERNS):
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(error_code_lower, _QUOTA_PATTERNS):
return False, "quota"
if _matches_any(lowered, _AUTH_PATTERNS):
return False, "auth"
@@ -159,6 +175,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
return True, "transient"
if _matches_any(lowered, _BUSY_PATTERNS):
return True, "busy"
# Treat upstream gateway garbage (malformed JSON, truncated responses)
# as a retriable transient — the deterministic char-position errors we
# see from vLLM/proxy layers usually clear on a re-roll.
if _matches_any(lowered, _UPSTREAM_GARBAGE_PATTERNS) or _matches_any(error_code_lower, _UPSTREAM_GARBAGE_PATTERNS):
return True, "upstream"
return False, "generic"
@@ -171,21 +192,34 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
seconds = max(1, round(wait_ms / 1000))
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
if reason == "busy":
reason_text = "服务繁忙"
elif reason == "upstream":
reason_text = "上游响应异常"
else:
reason_text = "网络/连接异常"
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
def _build_circuit_breaker_message(self) -> str:
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
def _build_user_message(self, exc: BaseException, reason: str) -> str:
detail = _extract_error_detail(exc)
def _build_user_message(self, reason: str) -> str:
if reason == "quota":
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
if reason == "auth":
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
if reason in {"busy", "transient"}:
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
return f"大模型因「{detail}」原因调用失败。"
if reason == "upstream":
return "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。"
# NOTE: We intentionally do NOT embed the raw exception detail into the
# user-facing message. Raw `Error code: 400 - {...}` strings — especially
# Python repr-style dict literals — get persisted as AI messages and then
# re-fed to the model on the next turn, where their unescaped quotes /
# braces can corrupt the upstream JSON payload and cause deterministic
# JSONDecodeError loops. Keep the user message clean; full detail is
# already in the backend log via `logger.warning(..., exc_info=exc)`.
return "大模型本次调用未能成功完成,请稍后再试或更换模型。"
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
try:
@@ -249,7 +283,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
self._record_failure()
return AIMessage(content=self._build_user_message(exc, reason))
return AIMessage(content=self._build_user_message(reason))
@override
async def awrap_model_call(
@@ -295,7 +329,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
self._record_failure()
return AIMessage(content=self._build_user_message(exc, reason))
return AIMessage(content=self._build_user_message(reason))
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
@@ -0,0 +1,109 @@
"""Inject stable citation numbering before RAG final answers."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from collections.abc import Awaitable, Callable
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import SystemMessage
from deerflow.runtime.references import build_latest_reference_prompt, build_reference_debug_summary
logger = logging.getLogger(__name__)
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
def _get_rag_debug_logger() -> logging.Logger:
debug_logger = logging.getLogger(_RAG_DEBUG_LOGGER_NAME)
if getattr(debug_logger, "_rag_file_configured", False):
return debug_logger
log_path = Path(os.environ.get("RAG_CITATION_DEBUG_LOG", "logs/rag_citation_debug.log"))
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.FileHandler(log_path, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
debug_logger.addHandler(handler)
debug_logger.setLevel(logging.INFO)
debug_logger.propagate = False
setattr(debug_logger, "_rag_file_configured", True)
setattr(debug_logger, "_rag_file_path", str(log_path))
return debug_logger
def _log_rag_debug(message: str, *args: Any) -> None:
logger.info(message, *args)
_get_rag_debug_logger().info(message, *args)
class RagCitationMiddleware(AgentMiddleware[AgentState]):
"""Give the model the same numbered references the frontend will render."""
@staticmethod
def _enabled(request: ModelRequest) -> bool:
runtime = getattr(request, "runtime", None)
context = getattr(runtime, "context", None) if runtime is not None else None
if isinstance(context, dict) and context.get("rag_mode_enabled"):
return True
config = getattr(runtime, "config", None) if runtime is not None else None
if isinstance(config, dict):
configurable = config.get("configurable")
if isinstance(configurable, dict) and configurable.get("rag_mode_enabled"):
return True
runtime_context = config.get("context")
if isinstance(runtime_context, dict) and runtime_context.get("rag_mode_enabled"):
return True
return False
@staticmethod
def _patch(request: ModelRequest) -> ModelRequest | None:
if not RagCitationMiddleware._enabled(request):
return None
messages = list(request.messages)
prompt, batch = build_latest_reference_prompt(messages)
if not prompt:
_log_rag_debug(
"middleware reference-prompt enabled=true prompt=false messages=%s",
len(messages),
)
return None
summary = build_reference_debug_summary(messages, [batch] if batch else None)
_log_rag_debug(
"middleware reference-prompt enabled=true prompt=true messages=%s tool_messages=%s "
"raw_items=%s normalized_items=%s kept_items=%s latest_batch_sources=%s",
summary["messages"],
summary["tool_messages"],
summary["raw_items"],
summary["normalized_items"],
summary["kept_items"],
len((batch or {}).get("sources") or []),
)
patched_messages = [
*messages,
SystemMessage(content=prompt),
]
return request.override(messages=patched_messages)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
patched = self._patch(request)
return handler(patched or request)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
patched = self._patch(request)
return await handler(patched or request)
@@ -840,10 +840,13 @@ class DeerFlowClient:
current_config = get_extensions_config()
config_data = {
"mcpServers": mcp_servers,
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
}
config_data = {
"mcpServers": mcp_servers,
"skills": {
name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -903,13 +906,20 @@ class DeerFlowClient:
if config_path is None:
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
extensions_config = get_extensions_config()
extensions_config.skills[name] = SkillStateConfig(enabled=enabled)
config_data = {
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()},
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()},
}
extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(name)
extensions_config.skills[name] = SkillStateConfig(
enabled=enabled,
display=current_skill_config.display if current_skill_config else None,
)
config_data = {
"mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
"skills": {
n: sc.model_dump(by_alias=True, exclude_none=True)
for n, sc in extensions_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -3,7 +3,7 @@
import json
import os
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -53,10 +53,63 @@ class McpServerConfig(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
class SkillDisplayTableColumn(BaseModel):
"""A configured table column for structured skill result display."""
label: str = Field(description="Column label shown in the frontend table")
field: str = Field(description="Field name or dot path read from each result item")
width: str | None = Field(default=None, description="Optional CSS width for this column")
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayConfig(BaseModel):
"""Frontend display adapter for structured skill results."""
enabled: bool = Field(default=False, description="Whether result display adaptation is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="none",
description="How the frontend should render this skill's structured results",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="doubleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=8, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
class ExtensionsConfig(BaseModel):
@@ -17,6 +17,7 @@ import json
import logging
import threading
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -37,10 +38,53 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class AppearanceSettings(BaseModel):
"""System-wide appearance defaults that any *new* user inherits.
Once a user explicitly toggles their theme in the UI, that choice is
persisted in the browser and supersedes this default — so changing
``default_theme`` here only affects users who have never touched the
toggle, and any future first-time loads.
"""
default_theme: Literal["light", "dark"] = Field(
default="light",
description="Theme applied for users who haven't picked their own (light / dark)",
)
model_config = ConfigDict(extra="ignore")
class CitationDisplaySettings(BaseModel):
"""System-wide cleanup rules for citation cards and detail dialogs."""
cleanup_words: list[str] = Field(
default_factory=list,
description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.",
)
model_config = ConfigDict(extra="ignore")
class PrivateSquareSettings(BaseModel):
"""Admin-configurable settings for the agent private-square (私密广场).
The password gates read access to all agents flagged ``is_private=True``.
The hash is stored opaquely — the router layer is responsible for hashing
and verifying so the harness package keeps zero dependencies on
``app.gateway.auth``.
"""
enabled: bool = Field(default=False, description="Whether the private square is offered to users at all")
password_hash: str = Field(default="", description="Opaque hash of the admin-configured password. Empty = no password set yet.")
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
model_config = ConfigDict(extra="ignore")
@@ -81,3 +81,39 @@ class AgentStore(ABC):
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
raise NotImplementedError
@abstractmethod
async def list_private(self) -> list[dict[str, Any]]:
"""Return every agent flagged ``is_private=True``.
Caller is responsible for authorization — typically the caller has
already verified the admin-configured private-square password.
"""
raise NotImplementedError
@abstractmethod
async def list_all(self) -> list[dict[str, Any]]:
"""Return every agent regardless of ownership/visibility.
Caller is responsible for authorization — typically the caller has
already verified ``system_role == "admin"``.
"""
raise NotImplementedError
@abstractmethod
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update any agent row by id, regardless of ownership.
Caller is responsible for authorization (admin only). Returns ``None``
if the agent does not exist.
"""
raise NotImplementedError
@abstractmethod
async def delete_any(self, agent_id: str) -> bool:
"""Delete any agent row by id, regardless of ownership.
Caller is responsible for authorization (admin only). Returns
``False`` if the agent does not exist.
"""
raise NotImplementedError
@@ -138,3 +138,29 @@ class MemoryAgentStore(AgentStore):
rows = [self._agents[aid] for aid in agent_ids if aid in self._agents and self._is_visible(self._agents[aid], user_id)]
rows.sort(key=lambda a: order_map.get(a["id"], 0))
return [_copy(a) for a in rows]
async def list_private(self) -> list[dict[str, Any]]:
rows = [_copy(a) for a in self._agents.values() if bool(a.get("is_private"))]
rows.sort(key=lambda a: a.get("created_at") or "")
return rows
async def list_all(self) -> list[dict[str, Any]]:
rows = [_copy(a) for a in self._agents.values()]
rows.sort(key=lambda a: a.get("created_at") or "")
return rows
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._agents.get(agent_id)
if row is None:
return None
for key in ("name", "description", "published", "is_private"):
if key in data:
row[key] = data[key]
row["updated_at"] = _now()
return _copy(row)
async def delete_any(self, agent_id: str) -> bool:
if agent_id not in self._agents:
return False
self._agents.pop(agent_id, None)
return True
@@ -21,6 +21,12 @@ class AgentRow(Base):
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# When True, the agent is moved into the password-gated "private square"
# (私密广场) and is hidden from the regular public square / built-in list.
# The flag is independent of ``published``: a private agent is still
# visible to its owner in the "我的" tab, and visible to anyone in the
# private-square listing once the password has been verified.
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
# featured rank (lower sorts first). Only ``published=True`` agents are
# eligible; the router enforces that invariant on writes. BIGINT so the
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore
@@ -28,10 +28,31 @@ class AgentRepository(AgentStore):
@staticmethod
def _visible_filter(user_id: str):
"""Visibility for single-record fetches (``get_visible``).
Lenient: a caller who already knows the agent id can fetch it even
when it's flagged ``is_private``. The private-square password is the
*discovery* gate (listings); individual reads are not re-gated, so a
bookmarked / linked private agent stays openable.
"""
return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True))
@staticmethod
def _listing_filter(user_id: str):
"""Visibility for *listings* — hides private-square agents from
non-owners so they only surface inside the password-gated endpoint.
Owners keep seeing their own private agents in '我的'.
"""
return or_(
AgentRow.user_id == user_id,
and_(
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
AgentRow.is_private.is_(False),
),
)
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
stmt = select(AgentRow).where(self._visible_filter(user_id)).order_by(AgentRow.created_at.asc())
stmt = select(AgentRow).where(self._listing_filter(user_id)).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
@@ -61,6 +82,7 @@ class AgentRepository(AgentStore):
description=data.get("description") or "",
user_id=data.get("user_id"),
published=bool(data.get("published", False)),
is_private=bool(data.get("is_private", False)),
created_at=now,
updated_at=now,
)
@@ -99,7 +121,7 @@ class AgentRepository(AgentStore):
row = await session.get(AgentRow, agent_id)
if row is None or row.user_id != user_id:
return None
for key in ("name", "description", "published"):
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
@@ -112,7 +134,7 @@ class AgentRepository(AgentStore):
row = await session.get(AgentRow, agent_id)
if row is None or row.user_id is not None:
return None
for key in ("name", "description", "published"):
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
@@ -157,7 +179,7 @@ class AgentRepository(AgentStore):
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(AgentRow)
.where(AgentRow.featured_order.is_not(None), self._visible_filter(user_id))
.where(AgentRow.featured_order.is_not(None), self._listing_filter(user_id))
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
)
async with self._sf() as session:
@@ -168,9 +190,43 @@ class AgentRepository(AgentStore):
if not agent_ids:
return []
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._visible_filter(user_id))
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._listing_filter(user_id))
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
return [_row_to_dict(r) for r in rows]
async def list_private(self) -> list[dict[str, Any]]:
stmt = select(AgentRow).where(AgentRow.is_private.is_(True)).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def list_all(self) -> list[dict[str, Any]]:
stmt = select(AgentRow).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(AgentRow, agent_id)
if row is None:
return None
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete_any(self, agent_id: str) -> bool:
async with self._sf() as session:
row = await session.get(AgentRow, agent_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,62 @@
"""Add ``agents.is_private`` for the private-square (私密广场) feature.
A nullable-default bool column flagging agents that should live in the
password-gated private square instead of the regular public square.
Revision ID: 20260526_01
Revises: 20260522_01
Create Date: 2026-05-26
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260526_01"
down_revision = "20260522_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
if _has_table("agents") and not _has_column("agents", "is_private"):
op.add_column(
"agents",
sa.Column(
"is_private",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
if _has_table("agents") and not _has_index("agents", "ix_agents_is_private"):
op.create_index("ix_agents_is_private", "agents", ["is_private"])
def downgrade() -> None:
if _has_index("agents", "ix_agents_is_private"):
op.drop_index("ix_agents_is_private", table_name="agents")
if _has_column("agents", "is_private"):
op.drop_column("agents", "is_private")
@@ -30,7 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
from deerflow.persistence.skills.model import SkillRow
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
@@ -51,6 +51,7 @@ __all__ = [
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SkillRow",
"SkillDisplayAdapterRow",
"TagAssignmentRow",
"TagRow",
"ThreadMetaRow",
@@ -68,6 +68,14 @@ class SkillStore(ABC):
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
raise NotImplementedError
@abstractmethod
async def list_all(self) -> list[dict[str, Any]]:
"""Return every skill record regardless of ownership/visibility.
Caller is responsible for authorization (admin only).
"""
raise NotImplementedError
@abstractmethod
async def list_published_names(self) -> set[str]:
"""Return the set of skill names with ``published = True`` (any owner).
@@ -134,5 +134,11 @@ class MemorySkillStore(SkillStore):
async def count_owned(self, user_id: str) -> int:
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
async def list_all(self) -> list[dict[str, Any]]:
return [
_with_derived(_copy(record)) # type: ignore[arg-type]
for record in self._skills.values()
]
async def list_published_names(self) -> set[str]:
return {name for name, record in self._skills.items() if record.get("published")}
@@ -31,3 +31,16 @@ class SkillRow(Base):
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class SkillDisplayAdapterRow(Base):
__tablename__ = "skill_display_adapters"
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
# Store JSON as text for maximum MySQL compatibility. Some deployments still
# run MySQL variants where native JSON columns or unquoted names such as
# ``display`` are not accepted consistently.
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -149,6 +149,12 @@ class SkillRepository(SkillStore):
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def list_all(self) -> list[dict[str, Any]]:
stmt = select(SkillRow).order_by(SkillRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def list_published_names(self) -> set[str]:
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
async with self._sf() as session:
@@ -0,0 +1,366 @@
"""Build stable per-turn citation batches from LangGraph messages."""
from __future__ import annotations
import json
from typing import Any
def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
batches: list[dict[str, Any]] = []
for index, message in enumerate(messages):
if _message_type(message) != "human":
continue
end = len(messages)
for cursor in range(index + 1, len(messages)):
if _message_type(messages[cursor]) == "human":
end = cursor
break
assistant = _find_last_assistant_answer(messages, index + 1, end)
if assistant is None:
continue
sources = _collect_sources(messages[index + 1 : end])
if sources:
message_id = _message_id(assistant) or str(index)
batches.append(
{
"id": message_id,
"assistant_message_id": message_id,
"sources": sources,
}
)
return batches
def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, Any] | None]:
human_index = _find_latest_human_index(messages)
if human_index is None:
return "", None
sources = _collect_sources(messages[human_index + 1 :])
if not sources:
return "", None
assistant = _find_last_assistant_answer(messages, human_index + 1, len(messages))
message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index)
batch = {
"id": message_id,
"assistant_message_id": message_id,
"sources": sources,
}
lines = [
"本轮检索结果已由系统统一合并并重新编号。最终回答必须使用下面这套编号。",
"如果你仍需要补充检索,请先继续调用工具;最终回答只能引用最新合并后的编号。",
"",
]
for source in sources:
title = source.get("title") or f"参考文献 {source.get('index')}"
meta = " · ".join(
str(item)
for item in (source.get("source"), source.get("time"))
if item
)
content = str(source.get("content") or source.get("snippet") or "").strip()
if len(content) > 520:
content = content[:517] + "..."
head = f"[{source.get('index')}] {title}"
if meta:
head += f"({meta})"
lines.append(head)
if content:
lines.append(content)
lines.append("")
lines.extend(
[
"引用规则:",
"1. 回答正文中的关键结论必须标注对应编号,例如 [1]、[2]。",
"2. 多个来源可写为 [1][3][5]。",
"3. 只能引用上方存在的编号,不要编造编号。",
"4. 不要使用工具原始编号,也不要重新排序编号。",
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
]
)
return "\n".join(lines).strip(), batch
def _find_latest_human_index(messages: list[Any]) -> int | None:
for index in range(len(messages) - 1, -1, -1):
if _message_type(messages[index]) == "human":
return index
return None
def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""Return compact counts for citation extraction logs."""
turn_summaries: list[dict[str, Any]] = []
total_tool_messages = 0
total_raw_items = 0
total_normalized_items = 0
total_kept_items = 0
for index, message in enumerate(messages):
if _message_type(message) != "human":
continue
end = len(messages)
for cursor in range(index + 1, len(messages)):
if _message_type(messages[cursor]) == "human":
end = cursor
break
tool_messages = 0
raw_items = 0
normalized_items = 0
kept_items = 0
for tool_message in messages[index + 1 : end]:
if _message_type(tool_message) != "tool":
continue
tool_messages += 1
items = _first_list(_extract_tool_payload(tool_message))
raw_items += len(items)
for raw_item in items:
normalized = _normalize_reference_item(raw_item, normalized_items + 1)
if not normalized:
continue
normalized_items += 1
kept_items += 1
total_tool_messages += tool_messages
total_raw_items += raw_items
total_normalized_items += normalized_items
total_kept_items += kept_items
turn_summaries.append(
{
"human_index": index,
"tool_messages": tool_messages,
"raw_items": raw_items,
"normalized_items": normalized_items,
"kept_items": kept_items,
}
)
batch_summaries = [
{
"id": batch.get("id"),
"assistant_message_id": batch.get("assistant_message_id"),
"source_count": len(batch.get("sources") or []),
}
for batch in (batches or build_reference_batches(messages))
]
return {
"messages": len(messages),
"turns": len(turn_summaries),
"tool_messages": total_tool_messages,
"raw_items": total_raw_items,
"normalized_items": total_normalized_items,
"kept_items": total_kept_items,
"turns_detail": turn_summaries,
"batches": batch_summaries,
}
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
sources: list[dict[str, Any]] = []
for message in messages:
if _message_type(message) != "tool":
continue
payload = _extract_tool_payload(message)
for raw_item in _first_list(payload):
normalized = _normalize_reference_item(raw_item, len(sources) + 1)
if not normalized:
continue
normalized["index"] = len(sources) + 1
sources.append(normalized)
return sources
def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None:
for index in range(end - 1, start - 1, -1):
candidate = messages[index]
if (
_message_type(candidate) == "ai"
and _message_id(candidate)
and _has_answer_content(candidate)
and not _has_tool_calls(candidate)
):
return candidate
return None
def _message_type(message: Any) -> str:
if isinstance(message, dict):
return str(message.get("type") or message.get("role") or "")
return str(getattr(message, "type", "") or "")
def _message_id(message: Any) -> str | None:
value = message.get("id") if isinstance(message, dict) else getattr(message, "id", None)
return str(value) if value else None
def _message_content(message: Any) -> Any:
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
def _has_tool_calls(message: Any) -> bool:
if isinstance(message, dict):
direct = message.get("tool_calls")
chunks = message.get("tool_call_chunks")
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
else:
direct = getattr(message, "tool_calls", None)
chunks = getattr(message, "tool_call_chunks", None)
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
return bool(direct or chunks or extra)
def _has_answer_content(message: Any) -> bool:
content = _message_content(message)
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(
isinstance(part, dict)
and part.get("type") == "text"
and str(part.get("text") or "").strip()
for part in content
)
return False
def _extract_tool_payload(message: Any) -> Any:
content = _message_content(message)
if isinstance(content, dict):
return _unwrap_tool_payload(content)
if isinstance(content, list):
texts: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
if isinstance(part.get("json"), dict):
return _unwrap_tool_payload(part["json"])
if part.get("type") == "json" and isinstance(part.get("json"), dict):
return _unwrap_tool_payload(part["json"])
if part.get("type") == "text" and isinstance(part.get("text"), str):
texts.append(part["text"])
unwrapped = _unwrap_tool_payload(part)
if unwrapped is not None:
return unwrapped
content = "\n".join(texts)
if isinstance(content, str):
return _parse_json_like(content)
return None
def _unwrap_tool_payload(value: dict[str, Any]) -> Any:
list_keys = ("results", "data", "items", "hits", "documents", "records", "sources")
if any(isinstance(value.get(key), list) for key in list_keys):
return value
for key in ("result", "output", "stdout", "content", "text", "payload", "response"):
nested = value.get(key)
if isinstance(nested, str):
parsed = _parse_json_like(nested)
if parsed is not None:
return parsed
if isinstance(nested, dict):
unwrapped = _unwrap_tool_payload(nested)
if unwrapped is not None:
return unwrapped
return value
def _parse_json_like(text: str) -> Any:
trimmed = text.strip()
if not trimmed:
return None
try:
return json.loads(trimmed)
except Exception:
pass
first = trimmed.find("{")
last = trimmed.rfind("}")
if 0 <= first < last:
try:
return json.loads(trimmed[first : last + 1])
except Exception:
return None
return None
def _first_list(payload: Any) -> list[Any]:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ("results", "data", "items", "hits", "documents", "records", "entries", "matches", "chunks", "sources"):
value = payload.get(key)
if isinstance(value, list):
return value
for key in ("result", "payload", "response", "output", "data"):
found = _first_list(payload.get(key))
if found:
return found
return []
def _nested_records(raw: Any) -> list[dict[str, Any]]:
if not isinstance(raw, dict):
return []
layers = [raw]
for key in ("metadata", "meta", "attrs", "attributes", "properties", "extra"):
nested = raw.get(key)
if isinstance(nested, dict):
layers.append(nested)
return layers
def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
for layer in layers:
for key in keys:
value = layer.get(key)
if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip()
return None
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
layers = _nested_records(raw)
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
score_raw = _pick(layers, ("score", "relevance", "similarity"))
score = None
if score_raw is not None:
try:
score = float(score_raw)
except Exception:
score = None
if not title:
title = source or f"结果 {fallback_index}"
if not content:
content = ""
if not title and not content and not url:
return None
return {
"id": item_id or url,
"skillName": "backend-reference",
"mode": "citation",
"title": title,
"type": "result",
"snippet": content,
"content": content,
"source": source,
"time": time,
"url": url,
"score": score,
"raw": raw,
}
def _reference_key(source: dict[str, Any]) -> str:
raw = source.get("raw") if isinstance(source.get("raw"), dict) else {}
item_id = source.get("id") or source.get("url") or raw.get("recUuid") or raw.get("uuid") or raw.get("docId") or raw.get("id")
if item_id:
return str(item_id).casefold().strip()
return "|".join(
str(source.get(key) or "").casefold().strip()
for key in ("title", "source", "time", "content", "snippet")
)
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
"""Return whether the selected shell is a PowerShell executable."""
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
@staticmethod
def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
@staticmethod
def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod
def _normalize_powershell_command(command: str) -> str:
"""Make bash-style command glue usable in Windows PowerShell 5.
The public tool is still named ``bash`` for model compatibility, but on
Windows local sandbox it may fall back to PowerShell. PowerShell 5 does
not support ``&&``, while the sandbox layer prepends commands with
``cd <workspace> &&``. Rewriting the separator keeps skill scripts
executable in that fallback shell.
"""
return command.replace("&&", ";")
@staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
for shell in candidates:
if os.path.isabs(shell):
if os.path.isfile(shell) and os.access(shell, os.X_OK):
@@ -302,30 +314,39 @@ class LocalSandbox(Sandbox):
resolved_command = self._resolve_paths_in_command(command)
shell = self._get_shell()
if os.name == "nt":
if self._is_powershell(shell):
args = [shell, "-NoProfile", "-Command", resolved_command]
if os.name == "nt":
if self._is_powershell(shell):
args = [
shell,
"-NoProfile",
"-Command",
self._normalize_powershell_command(resolved_command),
]
elif self._is_cmd_shell(shell):
args = [shell, "/c", resolved_command]
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
timeout=600,
)
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
timeout=600,
)
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
output = result.stdout
if result.stderr:
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
@@ -149,3 +149,58 @@ async def cascade_skill_unpublished_or_deleted(
counters["notified"] += 1
return counters
def sync_agent_skills_to_available(available_skill_names: set[str]) -> dict[str, Any]:
"""Strip skill references that no longer exist on disk from every agent.
Walks every agent dir and rewrites its ``config.yaml`` ``skills`` list to
contain only names that appear in ``available_skill_names``. Used by the
admin "agent-skills sync" endpoint to repair historical drift — skills
deleted before the cascade existed, deleted via a path that skipped the
cascade, or removed from the filesystem manually.
Synchronous because it only touches local files and the admin endpoint
runs it in a worker thread.
Returns ``{checked, modified, removals: [{agent_id, removed_skills}]}``.
"""
counters: dict[str, Any] = {"checked": 0, "modified": 0, "removals": []}
for agent_dir in _list_agent_dirs():
config_file = agent_dir / "config.yaml"
if not config_file.exists():
continue
counters["checked"] += 1
try:
with config_file.open("r", encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except yaml.YAMLError:
logger.warning("Sync: failed to parse %s, skipping", config_file)
continue
skills = data.get("skills")
if not isinstance(skills, list) or not skills:
continue
kept: list[str] = []
removed: list[str] = []
for name in skills:
if isinstance(name, str) and name in available_skill_names:
kept.append(name)
else:
removed.append(name if isinstance(name, str) else str(name))
if not removed:
continue
data["skills"] = kept
with config_file.open("w", encoding="utf-8") as f:
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
counters["modified"] += 1
counters["removals"].append({"agent_id": agent_dir.name, "removed_skills": removed})
logger.info("Sync: removed orphan skills %s from agent '%s'", removed, agent_dir.name)
return counters
+6 -6
View File
@@ -5,7 +5,7 @@ models:
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-e1c22b7931dc446f8b67e57878edd105
api_key: sk-9808fb2375534112ac275541aec14b9b
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
@@ -13,12 +13,12 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-v4-flash
display_name: DeepSeek V4 Flash
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: deepseek-v4-flash
api_key: sk-e1c22b7931dc446f8b67e57878edd105
base_url: https://api.deepseek.com
model: zai-org/GLM-5-FP8
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
base_url: https://api.us-west-2.modal.direct/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 4096