Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
d03f5fbbc0
79 files changed
+9502
-1647
No files matched your search
+4
-2
@@ -5,8 +5,10 @@ frontend-web/.vscode/
|
|||||||
# Claude Code local-only settings (per-user permissions, not shared)
|
# Claude Code local-only settings (per-user permissions, not shared)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
# Local runtime logs and pid files
|
# Local runtime logs and pid files
|
||||||
.runtime/
|
.runtime/
|
||||||
|
logs/
|
||||||
|
offline-backend-20260512/backend/logs/
|
||||||
|
|
||||||
# Frontend local/runtime artifacts
|
# Frontend local/runtime artifacts
|
||||||
frontend-web/.env
|
frontend-web/.env
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||||
|
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||||
import { I18nProvider } from "@/core/i18n/context";
|
import { I18nProvider } from "@/core/i18n/context";
|
||||||
import { detectLocale } from "@/core/i18n/locale";
|
import { detectLocale } from "@/core/i18n/locale";
|
||||||
import { ChatProvider } from "@/contexts/ChatContext";
|
import { ChatProvider } from "@/contexts/ChatContext";
|
||||||
@@ -31,13 +32,18 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
>
|
>
|
||||||
<I18nProvider initialLocale={locale}>
|
{/* QueryClientProvider must wrap ThemeProvider because the custom
|
||||||
<ThemeProvider>
|
ThemeProvider reads the admin-configured default via useQuery. */}
|
||||||
<ChatProvider>
|
<QueryClientProvider>
|
||||||
<QueryClientProvider>{children}</QueryClientProvider>
|
<I18nProvider initialLocale={locale}>
|
||||||
</ChatProvider>
|
<ThemeProvider>
|
||||||
</ThemeProvider>
|
<ChatProvider>
|
||||||
</I18nProvider>
|
<AuthSyncWatcher />
|
||||||
|
{children}
|
||||||
|
</ChatProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ListTodo,
|
ListTodo,
|
||||||
MessageSquareCode,
|
MessageSquareCode,
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
|
ShieldCheck,
|
||||||
Network,
|
Network,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -175,7 +176,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||||
{
|
{
|
||||||
id: "mgmt-tasks",
|
id: "mgmt-tasks",
|
||||||
label: "任务管理",
|
label: "定时任务",
|
||||||
icon: ListChecks,
|
icon: ListChecks,
|
||||||
path: "/page/strategy/scheduled/tasks",
|
path: "/page/strategy/scheduled/tasks",
|
||||||
},
|
},
|
||||||
@@ -193,6 +194,13 @@ const menuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/settings/prompt-prefix",
|
path: "/page/strategy/settings/prompt-prefix",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "mgmt-private-square",
|
||||||
|
label: "私密广场配置",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
path: "/page/strategy/settings/private-square",
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mgmt-llm-metrics",
|
id: "mgmt-llm-metrics",
|
||||||
label: "大模型调用统计",
|
label: "大模型调用统计",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
|
ShieldIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
useAgent,
|
useAgent,
|
||||||
useDeleteAgent,
|
useDeleteAgent,
|
||||||
useSetAgentFeatured,
|
useSetAgentFeatured,
|
||||||
|
useSetAgentPrivate,
|
||||||
useUpdateAgent,
|
useUpdateAgent,
|
||||||
} from "@/core/agents";
|
} from "@/core/agents";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
@@ -62,6 +64,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const deleteAgent = useDeleteAgent();
|
const deleteAgent = useDeleteAgent();
|
||||||
const updateAgent = useUpdateAgent();
|
const updateAgent = useUpdateAgent();
|
||||||
const setFeatured = useSetAgentFeatured();
|
const setFeatured = useSetAgentFeatured();
|
||||||
|
const setPrivate = useSetAgentPrivate();
|
||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -70,6 +73,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||||
const [editPublished, setEditPublished] = useState(agent.published);
|
const [editPublished, setEditPublished] = useState(agent.published);
|
||||||
|
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||||
agent.skills ?? [],
|
agent.skills ?? [],
|
||||||
);
|
);
|
||||||
@@ -89,11 +93,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const isOwned =
|
const isOwned =
|
||||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||||
const isFromOthers = !isBuiltin && !isOwned;
|
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
|
// Publish-toggle stays owner-only — built-ins are inherently visible to
|
||||||
// everyone so toggling published is moot.
|
// everyone so toggling published is moot.
|
||||||
const canEdit = isOwned || (isBuiltin && isAdmin);
|
const canEdit = isOwned || isAdmin;
|
||||||
const canDelete = isOwned || (isBuiltin && isAdmin);
|
const canDelete = isOwned || isAdmin;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editOpen) {
|
if (!editOpen) {
|
||||||
@@ -102,9 +107,11 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
setEditName(agent.name);
|
setEditName(agent.name);
|
||||||
setEditDescription(agent.description ?? "");
|
setEditDescription(agent.description ?? "");
|
||||||
setEditPublished(agent.published);
|
setEditPublished(agent.published);
|
||||||
|
setEditIsPrivate(agent.is_private);
|
||||||
setSelectedSkills(agent.skills ?? []);
|
setSelectedSkills(agent.skills ?? []);
|
||||||
}, [
|
}, [
|
||||||
agent.description,
|
agent.description,
|
||||||
|
agent.is_private,
|
||||||
agent.name,
|
agent.name,
|
||||||
agent.published,
|
agent.published,
|
||||||
agent.skills,
|
agent.skills,
|
||||||
@@ -145,6 +152,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
|
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (editIsPrivate && editPublished) {
|
||||||
|
toast.warning("私密广场与公开发布互斥,请只勾选其一");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateAgent.mutateAsync({
|
await updateAgent.mutateAsync({
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
@@ -152,7 +163,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
description: editDescription,
|
description: editDescription,
|
||||||
soul: editSoul,
|
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,
|
skills: selectedSkills,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -164,6 +178,13 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTogglePublished() {
|
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 {
|
try {
|
||||||
await updateAgent.mutateAsync({
|
await updateAgent.mutateAsync({
|
||||||
id: agent.id,
|
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() {
|
async function handleToggleFeatured() {
|
||||||
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
// 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
|
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
||||||
@@ -249,7 +291,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
来自他人
|
来自他人
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isOwned && agent.published && (
|
{isOwned && agent.published && !agent.is_private && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
@@ -259,6 +301,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
已发布
|
已发布
|
||||||
</Badge>
|
</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 && (
|
{agent.featured_order != null && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -314,13 +366,9 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
|
<CardFooter className="mt-auto flex flex-col items-stretch 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>
|
|
||||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||||
<div className="flex gap-1">
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -368,6 +416,24 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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 && (
|
{canEdit && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -392,6 +458,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -491,12 +561,41 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">公开发布</p>
|
<p className="text-sm font-medium">公开发布</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
打开后,该智能体对所有用户可见。
|
{editIsPrivate
|
||||||
|
? "已加入私密广场,不可发布;请先关闭私密广场。"
|
||||||
|
: "打开后,该智能体对所有用户可见。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={editPublished}
|
checked={editPublished && !editIsPrivate}
|
||||||
onCheckedChange={setEditPublished}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +1,138 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BotIcon, PlusIcon } from "lucide-react";
|
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
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 { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
|
||||||
import { AgentCard } from "./agent-card";
|
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() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||||
|
"mine",
|
||||||
|
);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
// Tag filtering stays server-side; name search must run on the *desensitized*
|
||||||
const { agents, isLoading } = useAgents({ search, tagIds });
|
// 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 storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
const currentUserId = storedAuth?.user_id ?? null;
|
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(
|
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(
|
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 = () => {
|
const handleNewAgent = () => {
|
||||||
router.push("/page/workspace/agents/new");
|
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) => (
|
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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{list.map((agent) => (
|
{list.map((agent) => (
|
||||||
@@ -65,6 +159,78 @@ export function AgentGallery() {
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="flex size-full flex-col">
|
<div className="flex size-full flex-col">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
@@ -84,14 +250,35 @@ export function AgentGallery() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
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"
|
className="flex flex-1 flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||||
<TabsList className="w-auto self-start">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
<TabsList className="w-auto self-start">
|
||||||
<TabsTrigger value="all">广场</TabsTrigger>
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
</TabsList>
|
<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
|
<TagSearchBar
|
||||||
targetType="agent"
|
targetType="agent"
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
@@ -123,6 +310,15 @@ export function AgentGallery() {
|
|||||||
renderGrid(myAgents)
|
renderGrid(myAgents)
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{privateFeatureAvailable && (
|
||||||
|
<TabsContent
|
||||||
|
value="private"
|
||||||
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||||
|
>
|
||||||
|
{renderPrivateTab()}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export function AgentManageDialog({
|
|||||||
|
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
const existing = new Set(agents.map((a) => a.id));
|
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]);
|
}, [agents, allAgents]);
|
||||||
|
|
||||||
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -10,6 +11,15 @@ import {
|
|||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable";
|
} 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 { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -19,9 +29,38 @@ import {
|
|||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
import { useThread } from "../messages/context";
|
import { useThread } from "../messages/context";
|
||||||
|
import { SkillDisplaySources } from "../skill-display-sources";
|
||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
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 }> = ({
|
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||||
children,
|
children,
|
||||||
@@ -30,7 +69,20 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
|
const artifactsCountRef = useRef(0);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
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 {
|
const {
|
||||||
artifacts,
|
artifacts,
|
||||||
@@ -43,14 +95,161 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (threadIdRef.current !== threadId) {
|
if (threadIdRef.current !== threadId) {
|
||||||
threadIdRef.current = threadId;
|
threadIdRef.current = threadId;
|
||||||
|
artifactsCountRef.current = 0;
|
||||||
deselect();
|
deselect();
|
||||||
|
setReferencesOpen(false);
|
||||||
|
setActiveReferenceBatchId(null);
|
||||||
|
setPinnedReferenceId(null);
|
||||||
|
setBackendReferenceBatches([]);
|
||||||
|
latestReferenceBatchIdRef.current = null;
|
||||||
|
manualReferenceBatchRef.current = false;
|
||||||
|
referenceDebugSignatureRef.current = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update artifacts from the current thread
|
// 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.
|
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||||
// if (
|
// if (
|
||||||
@@ -85,6 +284,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
}
|
}
|
||||||
return artifactsOpen;
|
return artifactsOpen;
|
||||||
}, [artifactsOpen, artifacts]);
|
}, [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 resizableIdBase = useMemo(() => {
|
||||||
const raw = pathname
|
const raw = pathname
|
||||||
@@ -95,13 +304,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (layoutRef.current) {
|
||||||
if (artifactPanelOpen) {
|
if (sidePanelOpen) {
|
||||||
layoutRef.current.setLayout(OPEN_MODE);
|
layoutRef.current.setLayout(OPEN_MODE);
|
||||||
} else {
|
} else {
|
||||||
layoutRef.current.setLayout(CLOSE_MODE);
|
layoutRef.current.setLayout(CLOSE_MODE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [artifactPanelOpen]);
|
}, [sidePanelOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -117,28 +326,79 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
id={`${resizableIdBase}-separator`}
|
id={`${resizableIdBase}-separator`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"opacity-33 hover:opacity-100",
|
"opacity-33 hover:opacity-100",
|
||||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||||
!artifactsOpen && "opacity-0",
|
!sidePanelOpen && "opacity-0",
|
||||||
)}
|
)}
|
||||||
id="artifacts"
|
id="artifacts"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
"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
|
<ArtifactFileDetail
|
||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={threadId}
|
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="relative flex size-full justify-center">
|
||||||
<div className="absolute top-1 right-1 z-30">
|
<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 };
|
export { ChatBox };
|
||||||
@@ -2,12 +2,19 @@
|
|||||||
|
|
||||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
|
||||||
export function useThreadChat() {
|
export function useThreadChat() {
|
||||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||||
const pathname = usePathname();
|
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 searchParams = useSearchParams();
|
||||||
const [threadId, setThreadId] = useState(() => {
|
const [threadId, setThreadId] = useState(() => {
|
||||||
@@ -37,7 +44,7 @@ export function useThreadChat() {
|
|||||||
}
|
}
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
setThreadId(threadIdFromPath);
|
setThreadId(threadIdFromPath);
|
||||||
}, [pathname, threadIdFromPath]);
|
}, [pathname, threadIdFromPath, locationKey]);
|
||||||
const isMock = searchParams.get("mock") === "true";
|
const isMock = searchParams.get("mock") === "true";
|
||||||
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
|
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
|
||||||
}
|
}
|
||||||
@@ -1,82 +1,77 @@
|
|||||||
import { ExternalLinkIcon } from "lucide-react";
|
import type { ComponentProps } from "react";
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||||
HoverCard,
|
|
||||||
HoverCardContent,
|
export function CitationLink({
|
||||||
HoverCardTrigger,
|
href,
|
||||||
} from "@/components/ui/hover-card";
|
children,
|
||||||
import { cn } from "@/lib/utils";
|
className,
|
||||||
|
...props
|
||||||
export function CitationLink({
|
}: ComponentProps<"a">) {
|
||||||
href,
|
const childrenText = normalizeCitationText(children);
|
||||||
children,
|
const displayText = childrenText ?? "";
|
||||||
...props
|
const referenceId = href?.startsWith("#reference-")
|
||||||
}: ComponentProps<"a">) {
|
? href.slice("#reference-".length)
|
||||||
const domain = extractDomain(href ?? "");
|
: null;
|
||||||
|
|
||||||
// Priority: children > domain
|
if (referenceId) {
|
||||||
const childrenText =
|
const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
|
||||||
typeof children === "string"
|
return (
|
||||||
? children.replace(/^citation:\s*/i, "")
|
<a
|
||||||
: null;
|
href={href}
|
||||||
const isGenericText = childrenText === "Source" || childrenText === "来源";
|
title={`查看参考文献 ${badgeLabel}`}
|
||||||
const displayText = (!isGenericText && childrenText) ?? domain;
|
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",
|
||||||
return (
|
className,
|
||||||
<HoverCard closeDelay={0} openDelay={0}>
|
)}
|
||||||
<HoverCardTrigger asChild>
|
onClick={(event) => {
|
||||||
<a
|
event.preventDefault();
|
||||||
href={href}
|
const batchId =
|
||||||
target="_blank"
|
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||||
rel="noopener noreferrer"
|
?.dataset.referenceBatchId;
|
||||||
className="inline-flex items-center"
|
window.dispatchEvent(
|
||||||
onClick={(e) => e.stopPropagation()}
|
new CustomEvent(SHOW_REFERENCE_EVENT, {
|
||||||
{...props}
|
detail: { batchId, referenceId },
|
||||||
>
|
}),
|
||||||
<Badge
|
);
|
||||||
variant="secondary"
|
const scroll = () => {
|
||||||
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
|
const target = document.getElementById(`reference-${referenceId}`);
|
||||||
>
|
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
{displayText}
|
};
|
||||||
<ExternalLinkIcon className="size-3" />
|
window.setTimeout(scroll, 240);
|
||||||
</Badge>
|
}}
|
||||||
</a>
|
{...props}
|
||||||
</HoverCardTrigger>
|
>
|
||||||
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
|
{badgeLabel}
|
||||||
<div className="p-3">
|
</a>
|
||||||
<div className="space-y-1">
|
);
|
||||||
{displayText && (
|
}
|
||||||
<h4 className="truncate text-sm leading-tight font-medium">
|
|
||||||
{displayText}
|
return (
|
||||||
</h4>
|
<a
|
||||||
)}
|
href={href}
|
||||||
{href && (
|
target={href?.startsWith("http") ? "_blank" : undefined}
|
||||||
<p className="text-muted-foreground truncate text-xs break-all">
|
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||||
{href}
|
className={cn(
|
||||||
</p>
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||||
)}
|
className,
|
||||||
</div>
|
)}
|
||||||
<a
|
{...props}
|
||||||
href={href}
|
>
|
||||||
target="_blank"
|
{displayText || children}
|
||||||
rel="noopener noreferrer"
|
</a>
|
||||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
);
|
||||||
>
|
}
|
||||||
Visit source
|
|
||||||
<ExternalLinkIcon className="size-3" />
|
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
|
||||||
</a>
|
if (typeof children !== "string") {
|
||||||
</div>
|
return null;
|
||||||
</HoverCardContent>
|
}
|
||||||
</HoverCard>
|
let text = children.trim();
|
||||||
);
|
while (/^citation:\s*/i.test(text)) {
|
||||||
}
|
text = text.replace(/^citation:\s*/i, "");
|
||||||
|
}
|
||||||
function extractDomain(url: string): string {
|
return text;
|
||||||
try {
|
}
|
||||||
return new URL(url).hostname.replace(/^www\./i, "");
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
|
BookMarkedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
@@ -86,6 +87,11 @@ import {
|
|||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||||
|
import {
|
||||||
|
isThreadRagModeLocked,
|
||||||
|
lockThreadRagMode,
|
||||||
|
useLocalSettings,
|
||||||
|
} from "@/core/settings";
|
||||||
|
|
||||||
|
|
||||||
import { useThread } from "./messages/context";
|
import { useThread } from "./messages/context";
|
||||||
@@ -177,6 +183,7 @@ export function InputBox({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
@@ -297,6 +304,15 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
}, [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(
|
const handleSubmit = useCallback(
|
||||||
async (message: PromptInputMessage) => {
|
async (message: PromptInputMessage) => {
|
||||||
if (status === "streaming") {
|
if (status === "streaming") {
|
||||||
@@ -308,9 +324,12 @@ export function InputBox({
|
|||||||
}
|
}
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
setFollowupsHidden(false);
|
setFollowupsHidden(false);
|
||||||
setFollowupsLoading(false);
|
setFollowupsLoading(false);
|
||||||
|
if (ragModeEnabled) {
|
||||||
// Guard against submitting before the initial model auto-selection
|
lockThreadRagMode(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against submitting before the initial model auto-selection
|
||||||
// effect has flushed thread settings to storage/state.
|
// effect has flushed thread settings to storage/state.
|
||||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
@@ -332,11 +351,13 @@ export function InputBox({
|
|||||||
onContextChange,
|
onContextChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
selectedModel?.supports_thinking,
|
ragModeEnabled,
|
||||||
status,
|
selectedModel?.supports_thinking,
|
||||||
],
|
status,
|
||||||
);
|
threadId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const requestFormSubmit = useCallback(() => {
|
const requestFormSubmit = useCallback(() => {
|
||||||
const form = promptRootRef.current?.querySelector("form");
|
const form = promptRootRef.current?.querySelector("form");
|
||||||
@@ -622,6 +643,33 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">记忆</span>
|
<span className="text-xs font-normal">记忆</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</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>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
|
|||||||
@@ -36,10 +36,13 @@ export function MarkdownContent({
|
|||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (typeof props.children === "string") {
|
if (props.href?.startsWith("#reference-")) {
|
||||||
const match = /^citation:(.+)$/.exec(props.children);
|
return <CitationLink {...props} />;
|
||||||
if (match) {
|
}
|
||||||
|
if (typeof props.children === "string") {
|
||||||
|
const match = /^citation:(.+)$/.exec(props.children);
|
||||||
|
if (match) {
|
||||||
const [, text] = match;
|
const [, text] = match;
|
||||||
return <CitationLink {...props}>{text}</CitationLink>;
|
return <CitationLink {...props}>{text}</CitationLink>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ import {
|
|||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
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 { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -54,6 +65,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useArtifactsOptional } from "../artifacts";
|
import { useArtifactsOptional } from "../artifacts";
|
||||||
import { FlipDisplay } from "../flip-display";
|
import { FlipDisplay } from "../flip-display";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
import { SourceCard } from "../skill-display-sources";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
|
||||||
@@ -74,6 +86,12 @@ export function MessageGroup({
|
|||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
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 lastToolCallStep = useMemo(() => {
|
||||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||||
return filteredSteps[filteredSteps.length - 1];
|
return filteredSteps[filteredSteps.length - 1];
|
||||||
@@ -142,7 +160,13 @@ export function MessageGroup({
|
|||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
) : (
|
) : (
|
||||||
<ToolCall key={step.id} {...step} isLoading={isLoading} />
|
<ToolCall
|
||||||
|
key={step.id}
|
||||||
|
{...step}
|
||||||
|
isLoading={isLoading}
|
||||||
|
skillDisplayMap={skillDisplayMap}
|
||||||
|
ragModeEnabled={settings.display.ragModeEnabled}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{lastToolCallStep && (
|
{lastToolCallStep && (
|
||||||
@@ -152,6 +176,8 @@ export function MessageGroup({
|
|||||||
{...lastToolCallStep}
|
{...lastToolCallStep}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
skillDisplayMap={skillDisplayMap}
|
||||||
|
ragModeEnabled={settings.display.ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
</FlipDisplay>
|
</FlipDisplay>
|
||||||
)}
|
)}
|
||||||
@@ -209,6 +235,8 @@ function ToolCall({
|
|||||||
result,
|
result,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
skillDisplayMap = new Map(),
|
||||||
|
ragModeEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -217,11 +245,56 @@ function ToolCall({
|
|||||||
result?: string | Record<string, unknown>;
|
result?: string | Record<string, unknown>;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||||
|
ragModeEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const artifacts = useArtifactsOptional();
|
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);
|
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) {
|
if (name === "web_search" || searchItems.length > 0) {
|
||||||
const query = resolveSearchQuery(args, result);
|
const query = resolveSearchQuery(args, result);
|
||||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
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 }) {
|
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const content = item.snippet ?? item.content;
|
const content = item.snippet ?? item.content;
|
||||||
|
|||||||
@@ -36,15 +36,20 @@ import {
|
|||||||
type FileInMessage,
|
type FileInMessage,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
|
import {
|
||||||
|
applyInlineCitations,
|
||||||
|
type RuntimeDisplaySource,
|
||||||
|
} from "@/core/skill-display/runtime";
|
||||||
import { humanMessagePlugins } from "@/core/streamdown";
|
import { humanMessagePlugins } from "@/core/streamdown";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { CitationLink } from "../citations/citation-link";
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
|
import { SkillDisplayBlock } from "../skill-display-block";
|
||||||
import { ThreadContext } from "./context";
|
import { ThreadContext } from "./context";
|
||||||
import { DownloadWordButton } from "./download-word-button";
|
import { DownloadWordButton } from "./download-word-button";
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
|
||||||
import { MessageTokenUsage } from "./message-token-usage";
|
import { MessageTokenUsage } from "./message-token-usage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +81,7 @@ export function MessageListItem({
|
|||||||
isLoading,
|
isLoading,
|
||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -83,6 +89,7 @@ export function MessageListItem({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
@@ -101,6 +108,7 @@ export function MessageListItem({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
/>
|
/>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
@@ -162,6 +170,7 @@ function MessageContent_({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -169,6 +178,7 @@ function MessageContent_({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
}) {
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
@@ -184,6 +194,12 @@ function MessageContent_({
|
|||||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||||
),
|
),
|
||||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
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/")) {
|
if (href?.startsWith("/mnt/")) {
|
||||||
const url = resolveArtifact(href);
|
const url = resolveArtifact(href);
|
||||||
return (
|
return (
|
||||||
@@ -220,8 +236,8 @@ function MessageContent_({
|
|||||||
if (isHuman) {
|
if (isHuman) {
|
||||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||||
}
|
}
|
||||||
return rawContent ?? "";
|
return applyInlineCitations(rawContent ?? "", displaySources);
|
||||||
}, [rawContent, isHuman]);
|
}, [rawContent, isHuman, displaySources]);
|
||||||
|
|
||||||
const filesList =
|
const filesList =
|
||||||
files && files.length > 0 ? (
|
files && files.length > 0 ? (
|
||||||
@@ -287,13 +303,15 @@ function MessageContent_({
|
|||||||
return (
|
return (
|
||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
{filesList}
|
{filesList}
|
||||||
<MarkdownContent
|
<div data-reference-batch-id={message.id} className="contents">
|
||||||
content={contentToDisplay}
|
<AssistantDisplayContent
|
||||||
isLoading={isLoading}
|
content={contentToDisplay}
|
||||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
displaySources={displaySources}
|
||||||
className="my-3"
|
isLoading={isLoading}
|
||||||
components={components}
|
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||||
/>
|
components={components}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MessageTokenUsage
|
<MessageTokenUsage
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -470,3 +488,159 @@ function RichFileCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageContent = memo(MessageContent_);
|
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 type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
@@ -20,6 +21,12 @@ import {
|
|||||||
hasToolCalls,
|
hasToolCalls,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
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 type { Subtask } from "@/core/tasks";
|
||||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
@@ -63,7 +70,6 @@ export function MessageList({
|
|||||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||||
@@ -71,6 +77,14 @@ export function MessageList({
|
|||||||
);
|
);
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
const messages = thread.messages;
|
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.
|
// Track which clarification messages are still awaiting an answer.
|
||||||
// Pending = clarification tool messages that appear after the last human message
|
// Pending = clarification tool messages that appear after the last human message
|
||||||
@@ -190,6 +204,16 @@ export function MessageList({
|
|||||||
{groupMessages(messages, (group) => {
|
{groupMessages(messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
return group.messages.map((msg) => {
|
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 (
|
return (
|
||||||
<MessageListItem
|
<MessageListItem
|
||||||
key={`${group.id}/${msg.id}`}
|
key={`${group.id}/${msg.id}`}
|
||||||
@@ -197,6 +221,7 @@ export function MessageList({
|
|||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -442,6 +467,24 @@ export type SetupAgentApprovalPayload = {
|
|||||||
skills?: string[];
|
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(
|
function getSetupAgentApprovalPayload(
|
||||||
message: unknown,
|
message: unknown,
|
||||||
): SetupAgentApprovalPayload | null {
|
): SetupAgentApprovalPayload | null {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { MoonIcon, SunIcon } from "lucide-react";
|
import { MoonIcon, SunIcon } from "lucide-react";
|
||||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -14,9 +16,17 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
|
import {
|
||||||
|
useAppearanceSettings,
|
||||||
|
useCitationDisplaySettings,
|
||||||
|
useUpdateAppearanceSettings,
|
||||||
|
useUpdateCitationDisplaySettings,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
@@ -31,6 +41,28 @@ export function AppearanceSettingsPage() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const currentTheme = theme;
|
const currentTheme = theme;
|
||||||
const [settings, setSettings] = useLocalSettings();
|
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(
|
const themeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -76,6 +108,43 @@ export function AppearanceSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@@ -125,6 +194,44 @@ export function AppearanceSettingsPage() {
|
|||||||
aria-label="回答速度指示器"
|
aria-label="回答速度指示器"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkillRow({
|
export function SkillRow({
|
||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
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,
|
CopyIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileCode2Icon,
|
FileCode2Icon,
|
||||||
|
FilesIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -25,12 +27,16 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
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 { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
useSetSkillDetail,
|
useSetSkillDetail,
|
||||||
useSkill,
|
useSkill,
|
||||||
useSkillContent,
|
useSkillContent,
|
||||||
|
useSkillFiles,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
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 {
|
function stripYamlFrontmatter(content: string): string {
|
||||||
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
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 [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
|
const [activeTab, setActiveTab] = useState<
|
||||||
null,
|
"detail" | "display" | "source" | "files" | null
|
||||||
);
|
>(null);
|
||||||
const [showRawSource, setShowRawSource] = useState(false);
|
const [showRawSource, setShowRawSource] = useState(false);
|
||||||
|
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||||
|
|
||||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||||
|
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
||||||
const {
|
const {
|
||||||
data: contentData,
|
data: contentData,
|
||||||
isLoading: isContentLoading,
|
isLoading: isContentLoading,
|
||||||
error: contentError,
|
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(() => {
|
useEffect(() => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setActiveTab(null);
|
setActiveTab(null);
|
||||||
setShowRawSource(false);
|
setShowRawSource(false);
|
||||||
|
setOpenedFilePath(null);
|
||||||
}, [open, skillName]);
|
}, [open, skillName]);
|
||||||
|
|
||||||
const skillBody = useMemo(() => {
|
const skillBody = useMemo(() => {
|
||||||
@@ -106,7 +134,7 @@ export function SkillDetailDialog({
|
|||||||
}, [contentData]);
|
}, [contentData]);
|
||||||
|
|
||||||
const defaultTab: "detail" | "source" =
|
const defaultTab: "detail" | "source" =
|
||||||
canManage && skill && !skill.detail ? "source" : "detail";
|
skill && !skill.detail ? "source" : "detail";
|
||||||
const selectedTab = activeTab ?? defaultTab;
|
const selectedTab = activeTab ?? defaultTab;
|
||||||
|
|
||||||
function startEditing() {
|
function startEditing() {
|
||||||
@@ -131,6 +159,7 @@ export function SkillDetailDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||||
@@ -194,11 +223,21 @@ export function SkillDetailDialog({
|
|||||||
{tx.detailSectionTitle}
|
{tx.detailSectionTitle}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<TabsTrigger value="source" className="text-xs">
|
<TabsTrigger value="display" className="text-xs">
|
||||||
<FileCode2Icon className="mr-1 size-3.5" />
|
<SlidersHorizontalIcon className="mr-1 size-3.5" />
|
||||||
{tx.contentSectionTitle}
|
展示适配
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
@@ -214,7 +253,6 @@ export function SkillDetailDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selectedTab === "source" &&
|
{selectedTab === "source" &&
|
||||||
canManage &&
|
|
||||||
!isContentLoading &&
|
!isContentLoading &&
|
||||||
!contentError && (
|
!contentError && (
|
||||||
<>
|
<>
|
||||||
@@ -299,7 +337,7 @@ export function SkillDetailDialog({
|
|||||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
<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" />
|
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||||
<p className="text-sm">{tx.detailEmpty}</p>
|
<p className="text-sm">{tx.detailEmpty}</p>
|
||||||
{canManage ? (
|
{isOwner ? (
|
||||||
<p className="mt-1 text-xs">
|
<p className="mt-1 text-xs">
|
||||||
Open the SKILL.md tab to view the skill source.
|
Open the SKILL.md tab to view the skill source.
|
||||||
</p>
|
</p>
|
||||||
@@ -312,10 +350,47 @@ export function SkillDetailDialog({
|
|||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="source"
|
value="display"
|
||||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||||
style={{ overflowY: "auto" }}
|
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 ? (
|
{isContentLoading ? (
|
||||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
@@ -341,10 +416,19 @@ export function SkillDetailDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
@@ -38,6 +39,21 @@ export function WorkspaceNavChatList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||||
@@ -49,7 +65,7 @@ export function WorkspaceNavChatList({
|
|||||||
asChild
|
asChild
|
||||||
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
|
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 />
|
<MessageSquarePlus />
|
||||||
<span>{t.sidebar.newChat}</span>
|
<span>{t.sidebar.newChat}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -141,7 +157,7 @@ export function WorkspaceNavChatList({
|
|||||||
>
|
>
|
||||||
<Link href="/page/workspace/scheduled/tasks">
|
<Link href="/page/workspace/scheduled/tasks">
|
||||||
<ListChecks />
|
<ListChecks />
|
||||||
<span>任务管理</span>
|
<span>定时任务</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
|
BotIcon,
|
||||||
BrainIcon,
|
BrainIcon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
MessageSquarePlusIcon,
|
MessageSquarePlusIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -143,6 +146,16 @@ export function WorkspaceNavMenu() {
|
|||||||
问答前缀配置
|
问答前缀配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/workspace/settings/private-square")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
私密广场配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -171,6 +184,22 @@ export function WorkspaceNavMenu() {
|
|||||||
定时任务管理
|
定时任务管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||||
|
>
|
||||||
|
<BotIcon />
|
||||||
|
智能体管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||||
|
>
|
||||||
|
<SparklesIcon />
|
||||||
|
技能管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTheme as useNextTheme } from 'next-themes';
|
import { useTheme as useNextTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||||
|
|
||||||
// 主题类型
|
// 主题类型
|
||||||
export type ThemeType = 'dark' | 'light';
|
export type ThemeType = 'dark' | 'light';
|
||||||
|
|
||||||
@@ -83,6 +85,12 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'strategy-theme';
|
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 {
|
function readPersistedTheme(): ThemeType {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -92,6 +100,16 @@ function readPersistedTheme(): ThemeType {
|
|||||||
return saved === 'dark' ? 'dark' : 'light';
|
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 {
|
interface ThemeContextType {
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
|
|||||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||||
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
|
// 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
|
||||||
|
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
|
||||||
|
// 「用户已选过」,admin 默认就再也注入不进来了。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
const current = localStorage.getItem(STORAGE_KEY);
|
const current = localStorage.getItem(STORAGE_KEY);
|
||||||
const legacy = localStorage.getItem('theme');
|
const legacy = localStorage.getItem('theme');
|
||||||
|
|
||||||
if (current !== 'light' && current !== 'dark') {
|
if (current && current !== 'light' && current !== 'dark') {
|
||||||
const fallback =
|
// Garbage value — wipe it so the boot-sync below can take over.
|
||||||
legacy === 'light' || legacy === 'dark' ? legacy : 'light';
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
setNextTheme(fallback);
|
}
|
||||||
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||||
setNextTheme(legacy);
|
// 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) {
|
if (legacy) {
|
||||||
localStorage.removeItem('theme');
|
localStorage.removeItem('theme');
|
||||||
}
|
}
|
||||||
}, [mounted, setNextTheme]);
|
}, [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
|
const theme: ThemeType = mounted
|
||||||
? resolvedTheme === 'light'
|
? resolvedTheme === 'dark'
|
||||||
? 'light'
|
? 'dark'
|
||||||
: 'dark'
|
: 'light'
|
||||||
: readPersistedTheme();
|
: readPersistedTheme();
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
|
markExplicitChoice();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTheme = (newTheme: ThemeType) => {
|
const setTheme = (newTheme: ThemeType) => {
|
||||||
setNextTheme(newTheme);
|
setNextTheme(newTheme);
|
||||||
|
markExplicitChoice();
|
||||||
};
|
};
|
||||||
|
|
||||||
const themeConfig = themes[theme];
|
const themeConfig = themes[theme];
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
|
|||||||
return data.agents;
|
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> {
|
export async function getAgent(id: string): Promise<Agent> {
|
||||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
||||||
if (!res.ok) throw new Error(`Agent '${id}' not found`);
|
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(
|
export async function setAgentFeatured(
|
||||||
id: string,
|
id: string,
|
||||||
order: number | null,
|
order: number | null,
|
||||||
@@ -124,6 +174,31 @@ export async function setAgentFeatured(
|
|||||||
return res.json() as Promise<Agent>;
|
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(
|
export async function checkAgentName(
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<{ available: boolean; name: string }> {
|
): Promise<{ available: boolean; name: string }> {
|
||||||
|
|||||||
@@ -6,16 +6,38 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
adminSyncAgentSkills,
|
||||||
createAgent,
|
createAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
getAgent,
|
getAgent,
|
||||||
getAgentSelector,
|
getAgentSelector,
|
||||||
listAgents,
|
listAgents,
|
||||||
|
listAllAgentsAdmin,
|
||||||
|
listPrivateAgents,
|
||||||
reorderFeaturedAgents,
|
reorderFeaturedAgents,
|
||||||
setAgentFeatured,
|
setAgentFeatured,
|
||||||
|
setAgentPrivate,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
} from "./api";
|
} 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[] } = {}) {
|
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||||
const search = params.search ?? "";
|
const search = params.search ?? "";
|
||||||
@@ -28,6 +50,21 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
|||||||
return { agents: data ?? [], isLoading, error };
|
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) {
|
export function useAgent(id: string | null | undefined) {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["agents", id],
|
queryKey: ["agents", id],
|
||||||
@@ -57,7 +94,8 @@ export function useUpdateAgent() {
|
|||||||
id: string;
|
id: string;
|
||||||
request: UpdateAgentRequest;
|
request: UpdateAgentRequest;
|
||||||
}) => updateAgent(id, request),
|
}) => updateAgent(id, request),
|
||||||
onSuccess: (_data, { id }) => {
|
onSuccess: (data, { id }) => {
|
||||||
|
applyAgentToCache(queryClient, data);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||||
},
|
},
|
||||||
@@ -96,7 +134,8 @@ export function useSetAgentFeatured() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
||||||
setAgentFeatured(id, order),
|
setAgentFeatured(id, order),
|
||||||
onSuccess: (_data, { id }) => {
|
onSuccess: (data, { id }) => {
|
||||||
|
applyAgentToCache(queryClient, data);
|
||||||
invalidateAgentQueries(queryClient);
|
invalidateAgentQueries(queryClient);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||||
},
|
},
|
||||||
@@ -110,3 +149,47 @@ export function useReorderFeaturedAgents() {
|
|||||||
onSuccess: () => invalidateAgentQueries(queryClient),
|
onSuccess: () => invalidateAgentQueries(queryClient),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the password-gated private square. ``password`` is sent on every
|
||||||
|
* fetch — wire ``enabled`` to false until the user has typed it so we don't
|
||||||
|
* hit the endpoint with an empty body and get a 403.
|
||||||
|
*/
|
||||||
|
export function usePrivateAgents(password: string | null) {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ["agents", "private-square", password ?? ""],
|
||||||
|
queryFn: () => listPrivateAgents(password!),
|
||||||
|
enabled: !!password,
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
return { agents: data ?? [], isLoading, error, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetAgentPrivate() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
|
||||||
|
setAgentPrivate(id, isPrivate),
|
||||||
|
onSuccess: (data, { id }) => {
|
||||||
|
applyAgentToCache(queryClient, data);
|
||||||
|
invalidateAgentQueries(queryClient);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["agents", "private-square"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminSyncAgentSkills() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: adminSyncAgentSkills,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.modified > 0) {
|
||||||
|
invalidateAgentQueries(queryClient);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export interface Agent {
|
|||||||
description: string;
|
description: string;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
is_private: boolean;
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
@@ -26,6 +27,7 @@ export interface CreateAgentRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
soul?: string;
|
soul?: string;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
|
is_private?: boolean;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
@@ -36,6 +38,7 @@ export interface UpdateAgentRequest {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
soul?: string | null;
|
soul?: string | null;
|
||||||
published?: boolean | null;
|
published?: boolean | null;
|
||||||
|
is_private?: boolean | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
|
||||||
|
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||||
|
|
||||||
|
function readUserId(): string | null {
|
||||||
|
return getStoredAuth()?.user_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
||||||
|
* cross-tab changes — when another tab logs in as a different user, this tab
|
||||||
|
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
||||||
|
* which then collides with the new token and surfaces as a 404 on "新对话".
|
||||||
|
* Reloading the tab is the simplest way to resync React state with the new
|
||||||
|
* identity.
|
||||||
|
*/
|
||||||
|
export function AuthSyncWatcher() {
|
||||||
|
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleStorage(event: StorageEvent) {
|
||||||
|
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextUserId = readUserId();
|
||||||
|
if (nextUserId === lastUserIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastUserIdRef.current = nextUserId;
|
||||||
|
if (nextUserId === null) {
|
||||||
|
window.location.replace("#/login");
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
return () => window.removeEventListener("storage", handleStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -501,6 +501,20 @@ export const enUS: Translations = {
|
|||||||
detailAdminOnlyHint:
|
detailAdminOnlyHint:
|
||||||
"Only an admin or the skill publisher can edit the details.",
|
"Only an admin or the skill publisher can edit the details.",
|
||||||
contentSectionTitle: "SKILL.md source",
|
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",
|
viewDetailAction: "View skill details",
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: "Archived skills",
|
archivedTitle: "Archived skills",
|
||||||
|
|||||||
@@ -432,6 +432,20 @@ export interface Translations {
|
|||||||
detailSaveSuccess: string;
|
detailSaveSuccess: string;
|
||||||
detailAdminOnlyHint: string;
|
detailAdminOnlyHint: string;
|
||||||
contentSectionTitle: 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;
|
viewDetailAction: string;
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: string;
|
archivedTitle: string;
|
||||||
|
|||||||
@@ -479,6 +479,20 @@ export const zhCN: Translations = {
|
|||||||
detailSaveSuccess: "详情已更新",
|
detailSaveSuccess: "详情已更新",
|
||||||
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
|
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
|
||||||
contentSectionTitle: "SKILL.md 原文",
|
contentSectionTitle: "SKILL.md 原文",
|
||||||
|
filesSectionTitle: "文件",
|
||||||
|
filesEmpty: "该技能目录下没有可显示的文件。",
|
||||||
|
filesLoadError: "加载文件失败",
|
||||||
|
filesPreviewBinary: "二进制文件无法预览,可点击下载查看。",
|
||||||
|
filesPreviewUnsupported: "该文件类型暂不支持预览。",
|
||||||
|
filesPreviewTooLarge: "文件过大,无法在线预览。",
|
||||||
|
filesEditAction: "编辑",
|
||||||
|
filesSaveAction: "保存",
|
||||||
|
filesCancelEditAction: "取消",
|
||||||
|
filesDownloadAction: "下载",
|
||||||
|
filesCopyPathAction: "复制路径",
|
||||||
|
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||||
|
filesSaveSuccess: "文件已保存",
|
||||||
|
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
||||||
viewDetailAction: "查看技能详情",
|
viewDetailAction: "查看技能详情",
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: "归档的技能",
|
archivedTitle: "归档的技能",
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type SearchResultItem = {
|
export type SearchResultItem = {
|
||||||
|
id?: string;
|
||||||
|
recUuid?: string;
|
||||||
|
uuid?: string;
|
||||||
|
docId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
snippet?: string;
|
snippet?: string;
|
||||||
@@ -300,6 +304,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
|||||||
const date =
|
const date =
|
||||||
pickFromLayers(layers, DATE_ALIASES) ??
|
pickFromLayers(layers, DATE_ALIASES) ??
|
||||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
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>();
|
const reserved = new Set<string>();
|
||||||
for (const value of [title, content, url, source, date]) {
|
for (const value of [title, content, url, source, date]) {
|
||||||
@@ -324,6 +332,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id,
|
||||||
|
recUuid,
|
||||||
|
uuid,
|
||||||
|
docId,
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
snippet: content,
|
snippet: content,
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||||
export type { LocalSettings } from "./local";
|
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
|
||||||
|
export type { LocalSettings } from "./local";
|
||||||
@@ -4,9 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
notification: {
|
notification: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
streamSpeedMeterEnabled: true,
|
streamSpeedMeterEnabled: true,
|
||||||
},
|
ragModeEnabled: false,
|
||||||
|
citationCleanupWords: [],
|
||||||
|
},
|
||||||
context: {
|
context: {
|
||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
mode: undefined,
|
mode: undefined,
|
||||||
@@ -15,8 +17,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||||
|
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
|
||||||
|
|
||||||
function isBrowser(): boolean {
|
function isBrowser(): boolean {
|
||||||
return typeof window !== "undefined";
|
return typeof window !== "undefined";
|
||||||
@@ -33,7 +36,17 @@ export interface LocalSettings {
|
|||||||
* the appearance settings page.
|
* the appearance settings page.
|
||||||
*/
|
*/
|
||||||
streamSpeedMeterEnabled: boolean;
|
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<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
| "thread_id"
|
| "thread_id"
|
||||||
@@ -78,10 +91,10 @@ export function getThreadModelName(threadId: string): string | undefined {
|
|||||||
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
|
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveThreadModelName(
|
export function saveThreadModelName(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
modelName: string | undefined,
|
modelName: string | undefined,
|
||||||
) {
|
) {
|
||||||
if (!isBrowser()) {
|
if (!isBrowser()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,8 +103,26 @@ export function saveThreadModelName(
|
|||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
return;
|
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(
|
export function applyThreadModelOverride(
|
||||||
settings: LocalSettings,
|
settings: LocalSettings,
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
|
|
||||||
|
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||||||
|
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||||||
|
const MAX_DISPLAY_ITEMS = 100;
|
||||||
|
|
||||||
|
export interface DisplaySourceItem {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
snippet: string;
|
||||||
|
source?: string;
|
||||||
|
time?: string;
|
||||||
|
url?: string;
|
||||||
|
content?: string;
|
||||||
|
score?: number;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||||
|
enabled: false,
|
||||||
|
mode: "none",
|
||||||
|
resultPath: "results",
|
||||||
|
titleField: null,
|
||||||
|
summaryField: null,
|
||||||
|
contentField: null,
|
||||||
|
sourceField: null,
|
||||||
|
typeField: null,
|
||||||
|
timeField: null,
|
||||||
|
scoreField: null,
|
||||||
|
urlField: null,
|
||||||
|
idField: null,
|
||||||
|
linkStrategy: "auto",
|
||||||
|
linkTemplate: null,
|
||||||
|
citationToken: "doubleBracket",
|
||||||
|
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||||||
|
customInstruction: "",
|
||||||
|
tableColumns: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidateFields: Record<string, string[]> = {
|
||||||
|
titleField: ["title", "m_title", "name", "subject", "headline"],
|
||||||
|
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||||
|
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||||
|
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||||
|
typeField: ["type", "category", "source_category", "source1"],
|
||||||
|
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||||
|
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||||
|
urlField: ["url", "link", "href", "source_url"],
|
||||||
|
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getByPath(value: unknown, path: string): unknown {
|
||||||
|
if (!path) return value;
|
||||||
|
return path.split(".").reduce<unknown>((acc, key) => {
|
||||||
|
if (acc && typeof acc === "object") {
|
||||||
|
return (acc as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstArray(
|
||||||
|
value: unknown,
|
||||||
|
path = "",
|
||||||
|
): { path: string; items: Record<string, unknown>[] } | null {
|
||||||
|
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
||||||
|
return { path, items: value as Record<string, unknown>[] };
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||||
|
const keys = Object.keys(sample);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const exact = keys.find((key) => key === candidate);
|
||||||
|
if (exact) return exact;
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const lowered = candidate.toLowerCase();
|
||||||
|
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
|
||||||
|
if (fuzzy) return fuzzy;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||||
|
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
||||||
|
const sample = found.items[0] ?? {};
|
||||||
|
const inferred = emptyDisplayConfig();
|
||||||
|
inferred.enabled = true;
|
||||||
|
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||||
|
inferred.resultPath = found.path || "results";
|
||||||
|
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
|
||||||
|
|
||||||
|
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||||
|
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillName === "knowledge-search-v2") {
|
||||||
|
inferred.titleField = inferred.titleField || "title";
|
||||||
|
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||||
|
inferred.contentField = inferred.contentField || "content_preview";
|
||||||
|
inferred.sourceField = inferred.sourceField || "source";
|
||||||
|
inferred.typeField = inferred.typeField || "source_category";
|
||||||
|
inferred.timeField = inferred.timeField || "publish_time";
|
||||||
|
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||||
|
inferred.idField = pickField(sample, ["recUuid", "rec_uuid", "uuid", "id"]) || inferred.idField;
|
||||||
|
inferred.linkStrategy = inferred.urlField ? "auto" : "id";
|
||||||
|
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
|
||||||
|
}
|
||||||
|
|
||||||
|
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
|
||||||
|
return inferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultTableColumns(
|
||||||
|
sample: Record<string, unknown>,
|
||||||
|
config: SkillDisplayConfig,
|
||||||
|
): NonNullable<SkillDisplayConfig["tableColumns"]> {
|
||||||
|
const preferred = [
|
||||||
|
config.titleField,
|
||||||
|
config.typeField,
|
||||||
|
config.sourceField,
|
||||||
|
config.timeField,
|
||||||
|
config.scoreField,
|
||||||
|
].filter((field): field is string => !!field);
|
||||||
|
const fallback = Object.keys(sample)
|
||||||
|
.filter((field) => !preferred.includes(field))
|
||||||
|
.slice(0, Math.max(0, 6 - preferred.length));
|
||||||
|
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
|
||||||
|
label: field,
|
||||||
|
field,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify(value: unknown, fallback = ""): string {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||||
|
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
||||||
|
encodeURIComponent(stringify(item[key])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||||
|
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
||||||
|
const id = config.idField ? stringify(item[config.idField]) : "";
|
||||||
|
if (config.linkStrategy === "url") return url || undefined;
|
||||||
|
if (config.linkStrategy === "id") {
|
||||||
|
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||||
|
}
|
||||||
|
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||||
|
const rawItems = getByPath(payload, config.resultPath);
|
||||||
|
if (!Array.isArray(rawItems)) return [];
|
||||||
|
const maxItems = resolveMaxItems(config, rawItems.length);
|
||||||
|
return rawItems
|
||||||
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||||
|
.slice(0, maxItems)
|
||||||
|
.map((item, index) => ({
|
||||||
|
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||||
|
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||||
|
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||||
|
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||||
|
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||||
|
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||||
|
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||||
|
score:
|
||||||
|
config.scoreField && typeof item[config.scoreField] === "number"
|
||||||
|
? (item[config.scoreField] as number)
|
||||||
|
: undefined,
|
||||||
|
url: resolveLink(item, config),
|
||||||
|
raw: item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMaxItems(
|
||||||
|
itemCount: number,
|
||||||
|
mode: SkillDisplayConfig["mode"],
|
||||||
|
): number {
|
||||||
|
const fallback =
|
||||||
|
mode === "citation" ? DEFAULT_CITATION_MAX_ITEMS : DEFAULT_LIST_MAX_ITEMS;
|
||||||
|
return Math.min(
|
||||||
|
MAX_DISPLAY_ITEMS,
|
||||||
|
Math.max(fallback, itemCount || fallback),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMaxItems(config: SkillDisplayConfig, itemCount: number): number {
|
||||||
|
const configured = Number(config.maxItems) || 0;
|
||||||
|
if (config.mode === "citation" && configured <= 8 && itemCount > configured) {
|
||||||
|
return Math.min(itemCount, MAX_DISPLAY_ITEMS);
|
||||||
|
}
|
||||||
|
return Math.min(
|
||||||
|
MAX_DISPLAY_ITEMS,
|
||||||
|
Math.max(1, configured || inferMaxItems(itemCount, config.mode)),
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
|
|
||||||
import type { Skill, SkillState } from "./type";
|
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||||
|
|
||||||
export interface ListSkillsParams {
|
export interface ListSkillsParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -20,6 +20,17 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
|||||||
return json.skills as Skill[];
|
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> {
|
export async function getSkill(skillName: string): Promise<Skill> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||||
@@ -46,6 +57,62 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
|||||||
return response.json();
|
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 {
|
export interface InstallSkillRequest {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
path: 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 {
|
export interface SetSkillDetailResponse {
|
||||||
name: string;
|
name: string;
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ import {
|
|||||||
enableSkill,
|
enableSkill,
|
||||||
getSkill,
|
getSkill,
|
||||||
getSkillContent,
|
getSkillContent,
|
||||||
|
getSkillFileContent,
|
||||||
|
getSkillFiles,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
reconcileCustomSkills,
|
reconcileCustomSkills,
|
||||||
|
saveSkillFile,
|
||||||
setSkillDetail,
|
setSkillDetail,
|
||||||
|
setSkillDisplay,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
setSkillState,
|
setSkillState,
|
||||||
validateSkillUpload,
|
validateSkillUpload,
|
||||||
} from "./api";
|
} 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[] } = {}) {
|
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||||
const search = params.search ?? "";
|
const search = params.search ?? "";
|
||||||
@@ -33,6 +37,21 @@ export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
|||||||
return { skills: data ?? [], isLoading, error };
|
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) {
|
export function useSkillContent(skillName: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["skill-content", skillName],
|
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() {
|
export function useEnableSkill() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -118,6 +162,10 @@ export function useSetSkillPublished() {
|
|||||||
}) => setSkillPublished(skillName, published),
|
}) => setSkillPublished(skillName, published),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
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),
|
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
// Deletion triggers the backend cascade that strips this skill from
|
||||||
|
// every referencing agent's config.yaml, so the cached agent list
|
||||||
|
// (agent.skills) is now stale.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkillFiles(skillName: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["skill-files", skillName],
|
||||||
|
queryFn: () => getSkillFiles(skillName!),
|
||||||
|
enabled: !!skillName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkillFileContent(skillName: string | null, path: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["skill-file", skillName, path],
|
||||||
|
queryFn: () => getSkillFileContent(skillName!, path!),
|
||||||
|
enabled: !!skillName && !!path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveSkillFile() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
skillName,
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
skillName: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}) => saveSkillFile(skillName, path, content),
|
||||||
|
onSuccess: (_data, { skillName, path }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-file", skillName, path] });
|
||||||
|
// SKILL.md edits also affect the source-tab view and skill metadata.
|
||||||
|
if (path === "SKILL.md") {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,36 @@ import type { Tag } from "@/core/tags";
|
|||||||
|
|
||||||
export type SkillState = "active" | "stale" | "archived";
|
export type SkillState = "active" | "stale" | "archived";
|
||||||
export type SkillSource = "agent" | "upload";
|
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 {
|
export interface Skill {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,6 +47,8 @@ export interface Skill {
|
|||||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
display?: SkillDisplayConfig | null;
|
||||||
|
displaySample?: Record<string, unknown> | null;
|
||||||
// Lifecycle fields — populated by curator. The main list filters on these
|
// Lifecycle fields — populated by curator. The main list filters on these
|
||||||
// so stale / archived skills appear in dedicated drawers instead.
|
// so stale / archived skills appear in dedicated drawers instead.
|
||||||
state?: SkillState;
|
state?: SkillState;
|
||||||
|
|||||||
@@ -42,3 +42,127 @@ export async function updatePromptPrefixSettings(
|
|||||||
}
|
}
|
||||||
return (await response.json()) as PromptPrefixSettings;
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
loadAppearanceSettings,
|
||||||
|
loadCitationDisplaySettings,
|
||||||
|
loadPrivateSquareStatus,
|
||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
|
updateAppearanceSettings,
|
||||||
|
updateCitationDisplaySettings,
|
||||||
|
updatePrivateSquareSettings,
|
||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
|
type AppearanceUpdate,
|
||||||
|
type CitationDisplayUpdate,
|
||||||
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
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() {
|
export function usePromptPrefixSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -27,3 +39,66 @@ export function useUpdatePromptPrefixSettings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePrivateSquareStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PRIVATE_SQUARE_KEY,
|
||||||
|
queryFn: () => loadPrivateSquareStatus(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePrivateSquareSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PrivateSquareUpdate) =>
|
||||||
|
updatePrivateSquareSettings(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(PRIVATE_SQUARE_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppearanceSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: APPEARANCE_KEY,
|
||||||
|
queryFn: () => loadAppearanceSettings(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
// The provider runs even on the login page (where the user has no auth)
|
||||||
|
// — gracefully tolerate the fetch failing rather than retrying.
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAppearanceSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: AppearanceUpdate) => updateAppearanceSettings(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(APPEARANCE_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCitationDisplaySettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: CITATION_DISPLAY_KEY,
|
||||||
|
queryFn: () => loadCitationDisplaySettings(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCitationDisplaySettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CitationDisplayUpdate) =>
|
||||||
|
updateCitationDisplaySettings(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(CITATION_DISPLAY_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,16 +3,19 @@ import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
|||||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
|
|
||||||
import { getAPIClient } from "../api";
|
import { getAPIClient } from "../api";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
|
import { getStoredAuth } from "../auth";
|
||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
import { useI18n } from "../i18n/hooks";
|
import { useI18n } from "../i18n/hooks";
|
||||||
import type { FileInMessage } from "../messages/utils";
|
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 { useUpdateSubtask } from "../tasks/context";
|
||||||
import type { UploadedFileInfo } from "../uploads";
|
import type { UploadedFileInfo } from "../uploads";
|
||||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||||
@@ -136,6 +139,42 @@ function getStreamErrorMessage(error: unknown): string {
|
|||||||
return "Request failed.";
|
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({
|
export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
context,
|
context,
|
||||||
@@ -146,6 +185,7 @@ export function useThreadStream({
|
|||||||
onToolEnd,
|
onToolEnd,
|
||||||
}: ThreadStreamOptions) {
|
}: ThreadStreamOptions) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const [localSettings] = useLocalSettings();
|
||||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||||
@@ -193,6 +233,7 @@ export function useThreadStream({
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
|
const navigate = useNavigate();
|
||||||
const runMetadataStorageRef = useRef<
|
const runMetadataStorageRef = useRef<
|
||||||
ReturnType<typeof getRunMetadataStorage> | undefined
|
ReturnType<typeof getRunMetadataStorage> | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -292,7 +333,29 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
setOptimisticMessages([]);
|
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) {
|
onFinish(state) {
|
||||||
listeners.current.onFinish?.(state.values);
|
listeners.current.onFinish?.(state.values);
|
||||||
@@ -307,9 +370,37 @@ export function useThreadStream({
|
|||||||
// Track message count before sending so we know when server has responded
|
// Track message count before sending so we know when server has responded
|
||||||
const prevMsgCountRef = useRef(thread.messages.length);
|
const prevMsgCountRef = useRef(thread.messages.length);
|
||||||
|
|
||||||
// Reset thread-local pending UI state when switching between threads so
|
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
||||||
// optimistic messages and in-flight guards do not leak across chat views.
|
// 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(() => {
|
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;
|
startedRef.current = false;
|
||||||
sendInFlightRef.current = false;
|
sendInFlightRef.current = false;
|
||||||
prevMsgCountRef.current = 0;
|
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: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -507,9 +604,10 @@ export function useThreadStream({
|
|||||||
? "low"
|
? "low"
|
||||||
: undefined),
|
: undefined),
|
||||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||||
thread_id: threadId,
|
rag_mode_enabled: ragModeEnabled,
|
||||||
},
|
thread_id: threadId,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -520,7 +618,7 @@ export function useThreadStream({
|
|||||||
sendInFlightRef.current = false;
|
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
|
// Merge thread with optimistic messages for display
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
writing_artifact_path?: string;
|
writing_artifact_path?: string;
|
||||||
memory_injection_enabled?: boolean;
|
memory_injection_enabled?: boolean;
|
||||||
agent_id?: string;
|
rag_mode_enabled?: boolean;
|
||||||
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,18 @@ interface Shortcut {
|
|||||||
export function useGlobalShortcuts(shortcuts: Shortcut[]) {
|
export function useGlobalShortcuts(shortcuts: Shortcut[]) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
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 meta = event.metaKey || event.ctrlKey;
|
||||||
|
const pressed = event.key.toLowerCase();
|
||||||
|
|
||||||
for (const shortcut of shortcuts) {
|
for (const shortcut of shortcuts) {
|
||||||
|
if (typeof shortcut.key !== "string") continue;
|
||||||
if (
|
if (
|
||||||
event.key.toLowerCase() === shortcut.key.toLowerCase() &&
|
pressed === shortcut.key.toLowerCase() &&
|
||||||
meta === shortcut.meta &&
|
meta === shortcut.meta &&
|
||||||
(shortcut.shift ?? false) === event.shiftKey
|
(shortcut.shift ?? false) === event.shiftKey
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
WorkspaceBody,
|
||||||
|
WorkspaceContainer,
|
||||||
|
WorkspaceHeader,
|
||||||
|
} from "@/components/workspace/workspace-container";
|
||||||
|
import { AgentCard } from "@/components/workspace/agents/agent-card";
|
||||||
|
import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents";
|
||||||
|
import type { AgentSkillsSyncResult } from "@/core/agents/api";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
|
||||||
|
export default function AdminAgentsPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const { agents, isLoading } = useAllAgentsAdmin(true);
|
||||||
|
const syncSkills = useAdminSyncAgentSkills();
|
||||||
|
const [syncResult, setSyncResult] = useState<AgentSkillsSyncResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
async function handleSyncSkills() {
|
||||||
|
try {
|
||||||
|
const result = await syncSkills.mutateAsync();
|
||||||
|
setSyncResult(result);
|
||||||
|
setSyncDialogOpen(true);
|
||||||
|
if (result.modified === 0) {
|
||||||
|
toast.success("所有智能体的技能均有效,无需清理");
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`已清理 ${result.modified} 个智能体中的失效技能引用`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAgents = useMemo(() => {
|
||||||
|
const needle = search.trim().toLowerCase();
|
||||||
|
if (!needle) return agents;
|
||||||
|
return agents.filter((a) =>
|
||||||
|
desensitize(a.name).toLowerCase().includes(needle),
|
||||||
|
);
|
||||||
|
}, [agents, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceContainer>
|
||||||
|
<WorkspaceHeader />
|
||||||
|
<WorkspaceBody>
|
||||||
|
<div className="flex size-full min-h-0 flex-col">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">智能体管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSyncSkills}
|
||||||
|
disabled={syncSkills.isPending}
|
||||||
|
title="扫描所有智能体的 config.yaml,移除指向已不存在技能的引用"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={`mr-1.5 size-4 ${syncSkills.isPending ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{syncSkills.isPending ? "正在同步…" : "同步技能"}
|
||||||
|
</Button>
|
||||||
|
<div className="relative w-64">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="按名称搜索"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
) : filteredAgents.length === 0 ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
|
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
当前没有可管理的智能体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mb-3 text-xs">
|
||||||
|
共 {filteredAgents.length} 个智能体。
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{filteredAgents.map((agent) => (
|
||||||
|
<AgentCard key={agent.id} agent={agent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WorkspaceBody>
|
||||||
|
|
||||||
|
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>同步技能 — 清理结果</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{syncResult
|
||||||
|
? `扫描 ${syncResult.checked} 个智能体配置,清理 ${syncResult.modified} 个。`
|
||||||
|
: ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{syncResult && syncResult.removals.length > 0 ? (
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
{syncResult.removals.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.agent_id}
|
||||||
|
className="rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{entry.agent_name
|
||||||
|
? desensitize(entry.agent_name)
|
||||||
|
: entry.agent_id}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
id: {entry.agent_id}
|
||||||
|
{entry.user_id ? ` · owner: ${entry.user_id}` : " · built-in"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{entry.removed_skills.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className="bg-muted rounded px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
所有智能体引用的技能均存在,未做任何修改。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setSyncDialogOpen(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</WorkspaceContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
WorkspaceBody,
|
||||||
|
WorkspaceContainer,
|
||||||
|
WorkspaceHeader,
|
||||||
|
} from "@/components/workspace/workspace-container";
|
||||||
|
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export default function AdminSkillsPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const { skills, isLoading, error } = useAllSkillsAdmin(true);
|
||||||
|
|
||||||
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
|
|
||||||
|
// Hide archived/stale entries here — they remain accessible from the regular
|
||||||
|
// skills settings dialog. The admin page is for live skills only.
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
const needle = search.trim().toLowerCase();
|
||||||
|
return skills
|
||||||
|
.filter((s) => (s.state ?? "active") === "active")
|
||||||
|
.filter((s) => !needle || desensitize(s.name).toLowerCase().includes(needle));
|
||||||
|
}, [skills, search]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, totalPages]);
|
||||||
|
const pagedSkills = useMemo(
|
||||||
|
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
|
[filteredSkills, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceContainer>
|
||||||
|
<WorkspaceHeader />
|
||||||
|
<WorkspaceBody>
|
||||||
|
<div className="flex size-full min-h-0 flex-col">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">技能管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-64 shrink-0">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="按名称搜索"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-destructive text-sm">
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
|
<SparklesIcon className="text-muted-foreground h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
当前没有可管理的技能。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
共 {filteredSkills.length} 个技能。
|
||||||
|
</p>
|
||||||
|
{pagedSkills.map((skill) => (
|
||||||
|
<SkillRow
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
第 {page} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WorkspaceBody>
|
||||||
|
</WorkspaceContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -231,14 +231,25 @@ export default function ChatPage() {
|
|||||||
</header>
|
</header>
|
||||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||||
<div className="flex size-full justify-center">
|
<div className="flex size-full justify-center">
|
||||||
<MessageList
|
{/*
|
||||||
className={cn("size-full", !isLandingState && "pt-10")}
|
Landing state covers two cases: (a) a brand-new chat that
|
||||||
threadId={threadId}
|
hasn't sent anything yet, and (b) the user clicking 「新对话」
|
||||||
thread={thread}
|
mid-stream. In (b) the underlying useStream's internal
|
||||||
paddingBottom={messageListPaddingBottom}
|
stream.values takes one or two effect ticks to clear, so
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
rendering MessageList unconditionally would leak the previous
|
||||||
onClarificationSubmit={handleClarificationSubmit}
|
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>
|
||||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2Icon, ShieldIcon } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
usePrivateSquareStatus,
|
||||||
|
useUpdatePrivateSquareSettings,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
|
export default function PrivateSquareSettingsPage() {
|
||||||
|
const { data, isLoading, error } = usePrivateSquareStatus();
|
||||||
|
const update = useUpdatePrivateSquareSettings();
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
// The status response never contains the password itself, so re-syncing
|
||||||
|
// ``enabled`` from a refetch wouldn't clobber any in-progress edit — but we
|
||||||
|
// still gate the seed on a one-shot ref to match the prompt-prefix page's
|
||||||
|
// semantics: the admin's draft must not flicker back to the server value if
|
||||||
|
// react-query refetches while they're typing.
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || initializedRef.current) return;
|
||||||
|
setEnabled(data.enabled);
|
||||||
|
initializedRef.current = true;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (password && password !== confirm) {
|
||||||
|
toast.error("两次输入的密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({
|
||||||
|
enabled,
|
||||||
|
// Only send the password field when the admin typed something —
|
||||||
|
// omitting it leaves the server-side hash untouched. The empty-string
|
||||||
|
// sentinel that clears the password is sent via the explicit "清除
|
||||||
|
// 密码" button below, not by leaving the field blank.
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
});
|
||||||
|
setPassword("");
|
||||||
|
setConfirm("");
|
||||||
|
toast.success("已保存");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearPassword = async () => {
|
||||||
|
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ password: "" });
|
||||||
|
toast.success("已清除密码");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "清除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">私密广场配置</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
|
||||||
|
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">启用私密广场</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
关闭后,「私密广场」页签不会显示;已放入私密广场的智能体也不会被列出。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
访问密码
|
||||||
|
{data?.password_set ? (
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||||
|
(已设置,留空则保持不变)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-destructive ml-2 text-xs font-normal">
|
||||||
|
(尚未设置)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm font-medium">确认密码</p>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
disabled={!password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={handleClearPassword}
|
||||||
|
disabled={!data?.password_set || update.isPending}
|
||||||
|
>
|
||||||
|
<ShieldIcon className="mr-1.5 size-4" />
|
||||||
|
清除密码
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={update.isPending}>
|
||||||
|
{update.isPending && (
|
||||||
|
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
|||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
|
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
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
|
<Route
|
||||||
path="settings/llm-metrics"
|
path="settings/llm-metrics"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ import ChatPage from "./ChatPage";
|
|||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
import AgentChatPage from "./AgentChatPage";
|
import AgentChatPage from "./AgentChatPage";
|
||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
|
import AdminAgentsPage from "./AdminAgentsPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
|
import AdminSkillsPage from "./AdminSkillsPage";
|
||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import MemoryPage from "./MemoryPage";
|
import MemoryPage from "./MemoryPage";
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
|
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
import SkillsPage from "./SkillsPage";
|
import SkillsPage from "./SkillsPage";
|
||||||
import UsersPage from "./UsersPage";
|
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
|
<Route
|
||||||
path="admin/tags"
|
path="admin/tags"
|
||||||
element={
|
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
|
<Route
|
||||||
path="settings/llm-metrics"
|
path="settings/llm-metrics"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -567,6 +567,20 @@
|
|||||||
transform: translateY(-50%) scale(1);
|
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 ─────────────────────────────────────────────── */
|
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
||||||
@@ -818,4 +832,4 @@
|
|||||||
* {
|
* {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
outline-color: var(--ring);
|
outline-color: var(--ring);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
# host shell; values in this file are what the container will receive.
|
# host shell; values in this file are what the container will receive.
|
||||||
|
|
||||||
# AI provider keys. Replace placeholders and rotate any test key before release.
|
# 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
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,22 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
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_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.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.paths import get_paths
|
||||||
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
@@ -34,6 +40,7 @@ class AgentResponse(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
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")
|
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")
|
builtin: bool = Field(default=False, description="True when user_id is null")
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
updated_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")
|
description: str = Field(default="", description="Agent description")
|
||||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
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")
|
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")
|
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")
|
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")
|
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")
|
description: str | None = Field(default=None, description="Updated description")
|
||||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||||
published: bool | None = Field(default=None, description="Updated publication state")
|
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)")
|
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)")
|
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)")
|
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 "",
|
description=record.get("description") or "",
|
||||||
user_id=record.get("user_id"),
|
user_id=record.get("user_id"),
|
||||||
published=bool(record.get("published", False)),
|
published=bool(record.get("published", False)),
|
||||||
|
is_private=bool(record.get("is_private", False)),
|
||||||
builtin=record.get("user_id") is None,
|
builtin=record.get("user_id") is None,
|
||||||
created_at=record.get("created_at"),
|
created_at=record.get("created_at"),
|
||||||
updated_at=record.get("updated_at"),
|
updated_at=record.get("updated_at"),
|
||||||
@@ -370,17 +380,21 @@ async def _get_editable_or_raise(
|
|||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
"""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
|
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
|
||||||
caller is a system admin. Custom agents owned by other users still 403.
|
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)
|
owned = await store.get_owned(agent_id, user_id)
|
||||||
if owned is not None:
|
if owned is not None:
|
||||||
return owned, "owner"
|
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)
|
visible = await store.get_visible(agent_id, user_id)
|
||||||
if visible is None:
|
if visible is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
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")
|
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
||||||
|
|
||||||
|
|
||||||
@@ -394,6 +408,14 @@ async def list_agents(
|
|||||||
request: Request,
|
request: Request,
|
||||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
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)"),
|
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:
|
) -> AgentsListResponse:
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
@@ -402,7 +424,12 @@ async def list_agents(
|
|||||||
tag_store: TagStore = get_tag_store(request)
|
tag_store: TagStore = get_tag_store(request)
|
||||||
await _maybe_sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
user_id = _current_user_id(request)
|
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
|
# 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 /
|
# parse, and dir self-heal — none of those feed UI fields the gallery /
|
||||||
# recommend dialog / selector actually render. Detail endpoints (single
|
# 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 "",
|
"description": request.description or "",
|
||||||
"user_id": _current_user_id(http_request),
|
"user_id": _current_user_id(http_request),
|
||||||
"published": request.published,
|
"published": request.published,
|
||||||
|
"is_private": request.is_private,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
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 ""
|
update_data["description"] = request.description or ""
|
||||||
if "published" in fields_set:
|
if "published" in fields_set:
|
||||||
update_data["published"] = bool(request.published)
|
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_name = update_data.get("name", current["name"])
|
||||||
next_description = update_data.get("description", current.get("description") or "")
|
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:
|
if not update_data:
|
||||||
record = current
|
record = current
|
||||||
elif edit_mode == "admin":
|
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:
|
else:
|
||||||
record = await store.update(agent_id, user_id, update_data)
|
record = await store.update(agent_id, user_id, update_data)
|
||||||
if record is None:
|
if record is None:
|
||||||
@@ -694,7 +734,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if edit_mode == "admin":
|
if edit_mode == "admin":
|
||||||
deleted = await store.delete_builtin(agent_id)
|
deleted = await store.delete_any(agent_id)
|
||||||
else:
|
else:
|
||||||
deleted = await store.delete(agent_id, user_id)
|
deleted = await store.delete(agent_id, user_id)
|
||||||
if not deleted:
|
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
|
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):
|
class AgentFeaturedReorderRequest(BaseModel):
|
||||||
"""Request body for admin to bulk-reorder the homepage featured list."""
|
"""Request body for admin to bulk-reorder the homepage featured list."""
|
||||||
|
|
||||||
@@ -801,3 +942,78 @@ async def reorder_featured_agents(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
|
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
|
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()
|
current_config = get_extensions_config()
|
||||||
|
|
||||||
# Convert request to dict format for JSON serialization
|
# Convert request to dict format for JSON serialization
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
"mcpServers": {name: server.model_dump(by_alias=True) for name, server in request.mcp_servers.items()},
|
||||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.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
|
# Write the configuration to file
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
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 app.gateway.deps import get_optional_user_from_request
|
||||||
from deerflow.config.system_settings import (
|
from deerflow.config.system_settings import (
|
||||||
PromptPrefixSettings,
|
PromptPrefixSettings,
|
||||||
@@ -31,6 +33,30 @@ class PromptPrefixUpdateRequest(BaseModel):
|
|||||||
prompt_prefix: str | None = None
|
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:
|
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
||||||
return PromptPrefixResponse(
|
return PromptPrefixResponse(
|
||||||
enabled=settings.enabled,
|
enabled=settings.enabled,
|
||||||
@@ -61,5 +87,116 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
|
|||||||
current = load_system_settings()
|
current = load_system_settings()
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
updated_prefix = current.prompt_prefix.model_copy(update=fields)
|
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)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import json
|
||||||
from typing import Any
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from langgraph.checkpoint.base import empty_checkpoint
|
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.authz import require_permission
|
||||||
from app.gateway.deps import get_checkpointer
|
from app.gateway.deps import get_checkpointer
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime import serialize_channel_values
|
from deerflow.runtime import serialize_channel_values
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.references import (
|
||||||
from deerflow.utils.time import coerce_iso, now_iso
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
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
|
# 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"})
|
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"})
|
||||||
|
|
||||||
|
|
||||||
def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Return ``metadata`` with server-controlled keys removed."""
|
"""Return ``metadata`` with server-controlled keys removed."""
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return metadata or {}
|
return metadata or {}
|
||||||
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
|
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)
|
next: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ThreadHistoryRequest(BaseModel):
|
class ThreadHistoryRequest(BaseModel):
|
||||||
"""Request body for checkpoint history."""
|
"""Request body for checkpoint history."""
|
||||||
|
|
||||||
limit: int = Field(default=10, ge=1, le=100, description="Maximum entries")
|
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}")
|
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."""
|
"""Derive thread status from checkpoint metadata."""
|
||||||
if checkpoint_tuple is None:
|
if checkpoint_tuple is None:
|
||||||
return "idle"
|
return "idle"
|
||||||
@@ -179,7 +246,251 @@ def _derive_thread_status(checkpoint_tuple) -> str:
|
|||||||
if tasks:
|
if tasks:
|
||||||
return "interrupted"
|
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)
|
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse:
|
async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse:
|
||||||
"""Get the latest state snapshot for a thread.
|
"""Get the latest state snapshot for a thread.
|
||||||
|
|
||||||
Channel values are serialized to ensure LangChain message objects
|
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,
|
parent_checkpoint_id=parent_checkpoint_id,
|
||||||
created_at=coerce_iso(metadata.get("created_at", "")),
|
created_at=coerce_iso(metadata.get("created_at", "")),
|
||||||
tasks=tasks,
|
tasks=tasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{thread_id}/state", response_model=ThreadStateResponse)
|
@router.get("/{thread_id}/reference-batches", response_model=ReferenceBatchesResponse)
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
|
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).
|
"""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
|
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.
|
# 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.
|
# 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]:
|
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}
|
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."""
|
"""Detect whether the caller asked for the prompt prefix to be applied."""
|
||||||
context = getattr(body, "context", None)
|
context = getattr(body, "context", None)
|
||||||
if isinstance(context, Mapping) and context.get("prompt_prefix_enabled"):
|
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)
|
metadata = getattr(body, "metadata", None)
|
||||||
if isinstance(metadata, Mapping) and metadata.get("prompt_prefix_enabled"):
|
if isinstance(metadata, Mapping) and metadata.get("prompt_prefix_enabled"):
|
||||||
return True
|
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
|
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
||||||
@@ -152,8 +196,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
|||||||
"model_name",
|
"model_name",
|
||||||
"mode",
|
"mode",
|
||||||
"thinking_enabled",
|
"thinking_enabled",
|
||||||
"reasoning_effort",
|
"reasoning_effort",
|
||||||
"writing_mode",
|
"rag_mode_enabled",
|
||||||
|
"writing_mode",
|
||||||
"writing_artifact_path",
|
"writing_artifact_path",
|
||||||
"canvas_artifact_title",
|
"canvas_artifact_title",
|
||||||
"canvas_artifact_type",
|
"canvas_artifact_type",
|
||||||
@@ -398,16 +443,29 @@ async def start_run(
|
|||||||
# Upsert thread metadata so the thread appears in /threads/search,
|
# Upsert thread metadata so the thread appears in /threads/search,
|
||||||
# even for threads that were never explicitly created via POST /threads
|
# even for threads that were never explicitly created via POST /threads
|
||||||
# (e.g. stateless runs).
|
# (e.g. stateless runs).
|
||||||
try:
|
requested_rag_mode = _rag_mode_requested(body)
|
||||||
existing = await run_ctx.thread_store.get(thread_id)
|
effective_rag_mode = requested_rag_mode
|
||||||
if existing is None:
|
try:
|
||||||
await run_ctx.thread_store.create(
|
existing = await run_ctx.thread_store.get(thread_id)
|
||||||
thread_id,
|
effective_rag_mode = requested_rag_mode or _thread_rag_mode_enabled(existing)
|
||||||
assistant_id=body.assistant_id,
|
if existing is None:
|
||||||
metadata=body.metadata,
|
metadata = dict(body.metadata or {})
|
||||||
)
|
if effective_rag_mode:
|
||||||
else:
|
metadata["rag_mode_enabled"] = True
|
||||||
await run_ctx.thread_store.update_status(thread_id, "running")
|
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,
|
# SDK 的 ``client.threads.create()`` 不传 assistant_id —— 先建出来空的 thread,
|
||||||
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
|
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
|
||||||
# assistant 筛选时拿不到关联。
|
# assistant 筛选时拿不到关联。
|
||||||
@@ -427,17 +485,21 @@ async def start_run(
|
|||||||
# applies once. We intentionally do NOT gate on thread-row existence: the
|
# applies once. We intentionally do NOT gate on thread-row existence: the
|
||||||
# thread_meta row is frequently pre-created, which previously skipped the
|
# thread_meta row is frequently pre-created, which previously skipped the
|
||||||
# prefix for genuine first turns.
|
# prefix for genuine first turns.
|
||||||
if _prompt_prefix_opted_in(body):
|
if _prompt_prefix_opted_in(body):
|
||||||
prefix_settings = load_system_settings().prompt_prefix
|
prefix_settings = load_system_settings().prompt_prefix
|
||||||
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
||||||
graph_input = _prepend_prompt_prefix(graph_input, 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 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``.
|
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
|
||||||
# The ``context`` field is a custom extension for the langgraph-compat layer
|
# The ``context`` field is a custom extension for the langgraph-compat layer
|
||||||
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
||||||
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
# 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
|
# Promote whitelisted top-level body fields into the context map so
|
||||||
# callers can pass them either nested under ``context`` or as flat
|
# callers can pass them either nested under ``context`` or as flat
|
||||||
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ gateway:
|
|||||||
port: 8001
|
port: 8001
|
||||||
|
|
||||||
models:
|
models:
|
||||||
- name: deepseek-chat
|
- name: zai-org/GLM-5-FP8
|
||||||
display_name: DeepSeek Chat
|
display_name: zai-org/GLM-5-FP8
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
model: deepseek-chat
|
model: zai-org/GLM-5-FP8
|
||||||
api_key: sk-1520028668cb4cc388ed8315ff1e856e
|
api_key: Bearer modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||||
base_url: https://api.deepseek.com
|
base_url: https://api.us-west-2.modal.direct/v1
|
||||||
request_timeout: 600.0
|
request_timeout: 600.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
@@ -121,7 +121,7 @@ sandbox:
|
|||||||
allow_host_bash: true
|
allow_host_bash: true
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
path: D:\project\bj\deerflow-server\skills
|
path: D:\cursor-work\offline-backend-20260512\backend\skills
|
||||||
security_scan:
|
security_scan:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
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.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||||
@@ -254,11 +255,14 @@ def _build_middlewares(
|
|||||||
List of middleware instances.
|
List of middleware instances.
|
||||||
"""
|
"""
|
||||||
resolved_app_config = app_config or get_app_config()
|
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)
|
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
|
# Inject the admin prompt prefix into the model request (kept out of the
|
||||||
# persisted/displayed message so the user never sees it).
|
# persisted/displayed message so the user never sees it).
|
||||||
middlewares.append(PromptPrefixMiddleware())
|
middlewares.append(PromptPrefixMiddleware())
|
||||||
|
if cfg.get("rag_mode_enabled", False):
|
||||||
|
middlewares.append(RagCitationMiddleware())
|
||||||
|
|
||||||
# Add summarization middleware if enabled
|
# Add summarization middleware if enabled
|
||||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||||
@@ -266,7 +270,6 @@ def _build_middlewares(
|
|||||||
middlewares.append(summarization_middleware)
|
middlewares.append(summarization_middleware)
|
||||||
|
|
||||||
# Add TodoList middleware if plan mode is enabled
|
# Add TodoList middleware if plan mode is enabled
|
||||||
cfg = _get_runtime_config(config)
|
|
||||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
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)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
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_mode = bool(cfg.get("writing_mode", False))
|
||||||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
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():
|
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,
|
is_bootstrap=True,
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
memory_injection_enabled=memory_injection_enabled,
|
memory_injection_enabled=memory_injection_enabled,
|
||||||
|
rag_mode_enabled=rag_mode_enabled,
|
||||||
),
|
),
|
||||||
state_schema=ThreadState,
|
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),
|
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
memory_injection_enabled=memory_injection_enabled,
|
memory_injection_enabled=memory_injection_enabled,
|
||||||
|
rag_mode_enabled=rag_mode_enabled,
|
||||||
is_notebook_mode=is_notebook_mode,
|
is_notebook_mode=is_notebook_mode,
|
||||||
notebook_source_ids=notebook_source_ids,
|
notebook_source_ids=notebook_source_ids,
|
||||||
),
|
),
|
||||||
|
|||||||
+74
-8
@@ -667,17 +667,25 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
|
|||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
def _get_cached_skills_prompt_section(
|
def _get_cached_skills_prompt_section(
|
||||||
skill_signature: tuple[tuple[str, str, str, str], ...],
|
skill_signature: tuple[tuple[str, str, str, str], ...],
|
||||||
|
display_signature: tuple[tuple[str, str, str], ...],
|
||||||
available_skills_key: tuple[str, ...] | None,
|
available_skills_key: tuple[str, ...] | None,
|
||||||
container_base_path: str,
|
container_base_path: str,
|
||||||
skill_evolution_section: str,
|
skill_evolution_section: str,
|
||||||
) -> 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]
|
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 = ""
|
skills_list = ""
|
||||||
if filtered:
|
if filtered:
|
||||||
skill_items = "\n".join(
|
items = []
|
||||||
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:
|
||||||
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>"
|
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
|
||||||
|
|
||||||
# Restricted mode: agent has an explicit skill allowlist.
|
# Restricted mode: agent has an explicit skill allowlist.
|
||||||
@@ -693,6 +701,19 @@ def _get_cached_skills_prompt_section(
|
|||||||
else:
|
else:
|
||||||
scope_note = ""
|
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>
|
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.
|
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}
|
{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
|
3. The skill file contains references to external resources under the same folder
|
||||||
4. Load referenced resources only when needed during execution
|
4. Load referenced resources only when needed during execution
|
||||||
5. Follow the skill's instructions precisely
|
5. Follow the skill's instructions precisely
|
||||||
|
{display_pattern}
|
||||||
{location_hint}{evolution_block}
|
{location_hint}{evolution_block}
|
||||||
{skills_list}
|
{skills_list}
|
||||||
|
|
||||||
</skill_system>"""
|
</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."""
|
"""Generate the skills prompt section with available skills list."""
|
||||||
skills = _get_enabled_skills_for_config(app_config)
|
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 ""
|
return ""
|
||||||
|
|
||||||
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
|
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
|
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
|
||||||
if not skill_signature and available_key is not None:
|
if not skill_signature and available_key is not None:
|
||||||
return ""
|
return ""
|
||||||
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
|
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:
|
def get_agent_soul(agent_id: str | None) -> str:
|
||||||
@@ -868,6 +914,7 @@ def apply_prompt_template(
|
|||||||
is_bootstrap: bool = False,
|
is_bootstrap: bool = False,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
memory_injection_enabled: bool = True,
|
memory_injection_enabled: bool = True,
|
||||||
|
rag_mode_enabled: bool = False,
|
||||||
is_notebook_mode: bool = False,
|
is_notebook_mode: bool = False,
|
||||||
notebook_source_ids: list[int] | None = None,
|
notebook_source_ids: list[int] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -902,7 +949,11 @@ def apply_prompt_template(
|
|||||||
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
||||||
|
|
||||||
# Get skills section
|
# 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)
|
# Get deferred tools section (tool_search)
|
||||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
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).
|
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
|
||||||
network_policy_section = _build_network_policy_section()
|
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
|
# Format the prompt with dynamic skills and memory
|
||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
@@ -957,7 +1023,7 @@ Writing mode is enabled for this run.
|
|||||||
acp_section=acp_and_mounts_section,
|
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:
|
if is_notebook_mode:
|
||||||
source_ids = notebook_source_ids or []
|
source_ids = notebook_source_ids or []
|
||||||
|
|||||||
+41
-7
@@ -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]):
|
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||||
@@ -139,9 +154,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
detail = _extract_error_detail(exc)
|
detail = _extract_error_detail(exc)
|
||||||
lowered = detail.lower()
|
lowered = detail.lower()
|
||||||
error_code = _extract_error_code(exc)
|
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)
|
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"
|
return False, "quota"
|
||||||
if _matches_any(lowered, _AUTH_PATTERNS):
|
if _matches_any(lowered, _AUTH_PATTERNS):
|
||||||
return False, "auth"
|
return False, "auth"
|
||||||
@@ -159,6 +175,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
return True, "transient"
|
return True, "transient"
|
||||||
if _matches_any(lowered, _BUSY_PATTERNS):
|
if _matches_any(lowered, _BUSY_PATTERNS):
|
||||||
return True, "busy"
|
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"
|
return False, "generic"
|
||||||
|
|
||||||
@@ -171,21 +192,34 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
|
|
||||||
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||||
seconds = max(1, round(wait_ms / 1000))
|
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} 秒后继续…"
|
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
|
||||||
|
|
||||||
def _build_circuit_breaker_message(self) -> str:
|
def _build_circuit_breaker_message(self) -> str:
|
||||||
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
||||||
|
|
||||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
def _build_user_message(self, reason: str) -> str:
|
||||||
detail = _extract_error_detail(exc)
|
|
||||||
if reason == "quota":
|
if reason == "quota":
|
||||||
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
||||||
if reason == "auth":
|
if reason == "auth":
|
||||||
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
||||||
if reason in {"busy", "transient"}:
|
if reason in {"busy", "transient"}:
|
||||||
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
|
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:
|
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -249,7 +283,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
)
|
)
|
||||||
if retriable:
|
if retriable:
|
||||||
self._record_failure()
|
self._record_failure()
|
||||||
return AIMessage(content=self._build_user_message(exc, reason))
|
return AIMessage(content=self._build_user_message(reason))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def awrap_model_call(
|
async def awrap_model_call(
|
||||||
@@ -295,7 +329,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
)
|
)
|
||||||
if retriable:
|
if retriable:
|
||||||
self._record_failure()
|
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:
|
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
|
||||||
|
|||||||
+109
@@ -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()
|
current_config = get_extensions_config()
|
||||||
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": mcp_servers,
|
"mcpServers": mcp_servers,
|
||||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
"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)
|
self._atomic_write_json(config_path, config_data)
|
||||||
|
|
||||||
@@ -903,13 +906,20 @@ class DeerFlowClient:
|
|||||||
if config_path is None:
|
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.")
|
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 = get_extensions_config()
|
||||||
extensions_config.skills[name] = SkillStateConfig(enabled=enabled)
|
current_skill_config = extensions_config.skills.get(name)
|
||||||
|
extensions_config.skills[name] = SkillStateConfig(
|
||||||
config_data = {
|
enabled=enabled,
|
||||||
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()},
|
display=current_skill_config.display if current_skill_config else None,
|
||||||
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()},
|
)
|
||||||
}
|
|
||||||
|
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)
|
self._atomic_write_json(config_path, config_data)
|
||||||
|
|
||||||
|
|||||||
+58
-5
@@ -3,7 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -53,10 +53,63 @@ class McpServerConfig(BaseModel):
|
|||||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
class SkillStateConfig(BaseModel):
|
class SkillDisplayTableColumn(BaseModel):
|
||||||
"""Configuration for a single skill's state."""
|
"""A configured table column for structured skill result display."""
|
||||||
|
|
||||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
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):
|
class ExtensionsConfig(BaseModel):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -37,10 +38,53 @@ class PromptPrefixSettings(BaseModel):
|
|||||||
model_config = ConfigDict(extra="ignore")
|
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):
|
class SystemSettings(BaseModel):
|
||||||
"""Top-level system settings document."""
|
"""Top-level system settings document."""
|
||||||
|
|
||||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
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")
|
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]]:
|
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."""
|
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
|
||||||
raise NotImplementedError
|
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
|
||||||
+26
@@ -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 = [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))
|
rows.sort(key=lambda a: order_map.get(a["id"], 0))
|
||||||
return [_copy(a) for a in rows]
|
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="")
|
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
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)
|
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
|
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
|
||||||
# featured rank (lower sorts first). Only ``published=True`` agents are
|
# featured rank (lower sorts first). Only ``published=True`` agents are
|
||||||
# eligible; the router enforces that invariant on writes. BIGINT so the
|
# eligible; the router enforces that invariant on writes. BIGINT so the
|
||||||
|
|||||||
+62
-6
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
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 sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.agents.base import AgentStore
|
from deerflow.persistence.agents.base import AgentStore
|
||||||
@@ -28,10 +28,31 @@ class AgentRepository(AgentStore):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _visible_filter(user_id: str):
|
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))
|
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]]:
|
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:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return [_row_to_dict(row) for row in result.scalars()]
|
return [_row_to_dict(row) for row in result.scalars()]
|
||||||
@@ -61,6 +82,7 @@ class AgentRepository(AgentStore):
|
|||||||
description=data.get("description") or "",
|
description=data.get("description") or "",
|
||||||
user_id=data.get("user_id"),
|
user_id=data.get("user_id"),
|
||||||
published=bool(data.get("published", False)),
|
published=bool(data.get("published", False)),
|
||||||
|
is_private=bool(data.get("is_private", False)),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -99,7 +121,7 @@ class AgentRepository(AgentStore):
|
|||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
if row is None or row.user_id != user_id:
|
if row is None or row.user_id != user_id:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published"):
|
for key in ("name", "description", "published", "is_private"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, data[key])
|
setattr(row, key, data[key])
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -112,7 +134,7 @@ class AgentRepository(AgentStore):
|
|||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
if row is None or row.user_id is not None:
|
if row is None or row.user_id is not None:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published"):
|
for key in ("name", "description", "published", "is_private"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, data[key])
|
setattr(row, key, data[key])
|
||||||
row.updated_at = datetime.now(UTC)
|
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]]:
|
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(AgentRow)
|
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())
|
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
|
||||||
)
|
)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
@@ -168,9 +190,43 @@ class AgentRepository(AgentStore):
|
|||||||
if not agent_ids:
|
if not agent_ids:
|
||||||
return []
|
return []
|
||||||
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
|
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:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
rows = list(result.scalars())
|
rows = list(result.scalars())
|
||||||
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
|
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
|
||||||
return [_row_to_dict(r) for r in rows]
|
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
|
||||||
+62
@@ -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")
|
||||||
+2
-1
@@ -30,7 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
|
|||||||
ScheduledTaskSubscriptionRow,
|
ScheduledTaskSubscriptionRow,
|
||||||
SchedulerThreadRow,
|
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.tags.model import TagAssignmentRow, TagRow
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||||
@@ -51,6 +51,7 @@ __all__ = [
|
|||||||
"ScheduledTaskSubscriptionRow",
|
"ScheduledTaskSubscriptionRow",
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"SkillRow",
|
"SkillRow",
|
||||||
|
"SkillDisplayAdapterRow",
|
||||||
"TagAssignmentRow",
|
"TagAssignmentRow",
|
||||||
"TagRow",
|
"TagRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ class SkillStore(ABC):
|
|||||||
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||||
raise NotImplementedError
|
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
|
@abstractmethod
|
||||||
async def list_published_names(self) -> set[str]:
|
async def list_published_names(self) -> set[str]:
|
||||||
"""Return the set of skill names with ``published = True`` (any owner).
|
"""Return the set of skill names with ``published = True`` (any owner).
|
||||||
|
|||||||
+6
@@ -134,5 +134,11 @@ class MemorySkillStore(SkillStore):
|
|||||||
async def count_owned(self, user_id: str) -> int:
|
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)
|
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]:
|
async def list_published_names(self) -> set[str]:
|
||||||
return {name for name, record in self._skills.items() if record.get("published")}
|
return {name for name, record in self._skills.items() if record.get("published")}
|
||||||
+13
@@ -31,3 +31,16 @@ class SkillRow(Base):
|
|||||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
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))
|
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))
|
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:
|
async with self._sf() as session:
|
||||||
return await session.scalar(stmt) or 0
|
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]:
|
async def list_published_names(self) -> set[str]:
|
||||||
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
||||||
async with self._sf() as session:
|
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")
|
||||||
|
)
|
||||||
+48
-27
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
|
|||||||
"""Return whether the selected shell is a PowerShell executable."""
|
"""Return whether the selected shell is a PowerShell executable."""
|
||||||
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
|
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_cmd_shell(shell: str) -> bool:
|
def _is_cmd_shell(shell: str) -> bool:
|
||||||
"""Return whether the selected shell is cmd.exe."""
|
"""Return whether the selected shell is cmd.exe."""
|
||||||
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
def _normalize_powershell_command(command: str) -> str:
|
||||||
"""Return the first executable shell path or command found from candidates."""
|
"""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:
|
for shell in candidates:
|
||||||
if os.path.isabs(shell):
|
if os.path.isabs(shell):
|
||||||
if os.path.isfile(shell) and os.access(shell, os.X_OK):
|
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)
|
resolved_command = self._resolve_paths_in_command(command)
|
||||||
shell = self._get_shell()
|
shell = self._get_shell()
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
if self._is_powershell(shell):
|
if self._is_powershell(shell):
|
||||||
args = [shell, "-NoProfile", "-Command", resolved_command]
|
args = [
|
||||||
|
shell,
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
self._normalize_powershell_command(resolved_command),
|
||||||
|
]
|
||||||
elif self._is_cmd_shell(shell):
|
elif self._is_cmd_shell(shell):
|
||||||
args = [shell, "/c", resolved_command]
|
args = [shell, "/c", resolved_command]
|
||||||
else:
|
else:
|
||||||
args = [shell, "-c", resolved_command]
|
args = [shell, "-c", resolved_command]
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
args,
|
args,
|
||||||
shell=False,
|
shell=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600,
|
encoding="utf-8",
|
||||||
)
|
errors="replace",
|
||||||
else:
|
timeout=600,
|
||||||
args = [shell, "-c", resolved_command]
|
)
|
||||||
result = subprocess.run(
|
else:
|
||||||
args,
|
args = [shell, "-c", resolved_command]
|
||||||
shell=False,
|
result = subprocess.run(
|
||||||
capture_output=True,
|
args,
|
||||||
text=True,
|
shell=False,
|
||||||
timeout=600,
|
capture_output=True,
|
||||||
)
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
output = result.stdout
|
output = result.stdout
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
output += f"\nStd Error:\n{result.stderr}" if output else 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
|
counters["notified"] += 1
|
||||||
|
|
||||||
return counters
|
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
|
||||||
@@ -5,7 +5,7 @@ models:
|
|||||||
display_name: DeepSeek Chat
|
display_name: DeepSeek Chat
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
model: deepseek-chat
|
model: deepseek-chat
|
||||||
api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
api_key: sk-9808fb2375534112ac275541aec14b9b
|
||||||
base_url: https://api.deepseek.com
|
base_url: https://api.deepseek.com
|
||||||
request_timeout: 600.0
|
request_timeout: 600.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
@@ -13,12 +13,12 @@ models:
|
|||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_thinking: true
|
supports_thinking: true
|
||||||
- name: deepseek-v4-flash
|
- name: zai-org/GLM-5-FP8
|
||||||
display_name: DeepSeek V4 Flash
|
display_name: zai-org/GLM-5-FP8
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
model: deepseek-v4-flash
|
model: zai-org/GLM-5-FP8
|
||||||
api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||||
base_url: https://api.deepseek.com
|
base_url: https://api.us-west-2.modal.direct/v1
|
||||||
request_timeout: 600.0
|
request_timeout: 600.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
|
|||||||
Reference in new issue
Block a user