Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
d6ff00226a
34 files changed
+1322
-234
No files matched your search
@@ -29,7 +29,7 @@ import {
|
||||
} from "@/core/admin/hooks";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
|
||||
function getEntries(data: Record<string, unknown>): string[] {
|
||||
export function getEntries(data: Record<string, unknown>): string[] {
|
||||
const raw = (data as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
||||
@@ -251,7 +251,7 @@ function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string
|
||||
);
|
||||
}
|
||||
|
||||
function V1BucketView({
|
||||
export function V1BucketView({
|
||||
data,
|
||||
agent,
|
||||
userId,
|
||||
@@ -319,7 +319,7 @@ function V1BucketView({
|
||||
|
||||
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||
|
||||
function MemoryEntryList({
|
||||
export function MemoryEntryList({
|
||||
entries,
|
||||
userId,
|
||||
agent,
|
||||
@@ -490,7 +490,7 @@ function MemoryEntryList({
|
||||
|
||||
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
||||
|
||||
function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
export function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
const { data: memory, isLoading } = useUserMemory(userId);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { AdminMessage } from "@/core/admin/api";
|
||||
import {
|
||||
useUserMemory,
|
||||
useUserThreadMessages,
|
||||
useUserThreads,
|
||||
} from "@/core/admin/hooks";
|
||||
import type { UserListItem } from "@/core/auth/api";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
getEntries,
|
||||
MemoryEntryList,
|
||||
V1BucketView,
|
||||
} from "./admin-memory-page";
|
||||
|
||||
// ── Helpers (shared with AdminUserActivityPage) ─────────────────────────────
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (content == null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block && typeof block === "object") {
|
||||
const text = (block as Record<string, unknown>).text;
|
||||
if (typeof text === "string") return text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
if (typeof content === "object") {
|
||||
const inner = (content as Record<string, unknown>).content;
|
||||
if (inner !== undefined) return extractText(inner);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function roleOf(message: AdminMessage): "user" | "assistant" | null {
|
||||
const type = (message.type ?? "").toLowerCase();
|
||||
if (type === "human") return "user";
|
||||
if (type === "ai" || type === "aimessagechunk") return "assistant";
|
||||
return null;
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
// ── UserInfoPanel ───────────────────────────────────────────────────────────
|
||||
|
||||
function UserInfoPanel({ user }: { user: UserListItem }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 text-sm">
|
||||
<dt className="text-muted-foreground font-medium">邮箱</dt>
|
||||
<dd className="break-all">{user.email}</dd>
|
||||
<dt className="text-muted-foreground font-medium">角色</dt>
|
||||
<dd>
|
||||
{user.system_role === "admin" ? (
|
||||
<Badge variant="default">管理员</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">普通用户</Badge>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground font-medium">Skills</dt>
|
||||
<dd className="tabular-nums">{user.skill_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">Agents</dt>
|
||||
<dd className="tabular-nums">{user.agent_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">User ID</dt>
|
||||
<dd className="font-mono text-xs text-muted-foreground break-all">{user.id}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MessageRow / MemoryBucketView (audit sub-components) ────────────────────
|
||||
|
||||
function MessageRow({ message }: { message: AdminMessage }) {
|
||||
const role = roleOf(message);
|
||||
if (!role) return null;
|
||||
const text = extractText(message.content).trim();
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
role === "user" ? "items-start" : "items-end",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{role === "user" ? "用户提问" : "AI 回答"}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
|
||||
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UserAuditPanel ──────────────────────────────────────────────────────────
|
||||
|
||||
function UserAuditPanel({ userId }: { userId: string }) {
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
|
||||
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
|
||||
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
|
||||
userId,
|
||||
threadId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Thread list */}
|
||||
<div className="flex w-60 shrink-0 flex-col border-r">
|
||||
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
|
||||
会话列表{threads ? `(${threads.length})` : ""}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{threadsLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!threadsLoading && (threads?.length ?? 0) === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
该用户暂无会话
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col p-1">
|
||||
{(threads ?? []).map((thread) => (
|
||||
<button
|
||||
key={thread.thread_id}
|
||||
type="button"
|
||||
onClick={() => setThreadId(thread.thread_id)}
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 rounded-md px-2 py-2 text-left",
|
||||
thread.thread_id === threadId ? "bg-accent" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm">
|
||||
{thread.display_name || "未命名会话"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmtTime(thread.updated_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{!threadId && (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
|
||||
<MessageSquareIcon className="size-6" />
|
||||
请从左侧选择一个会话
|
||||
</div>
|
||||
)}
|
||||
{threadId && messagesLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{threadId && !messagesLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(messages ?? []).map((m, i) => (
|
||||
<MessageRow key={m.id ?? i} message={m} />
|
||||
))}
|
||||
{(messages ?? []).every((m) => !roleOf(m)) && (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
该会话暂无问答记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentMemoryPanel ─────────────────────────────────────────────────────────
|
||||
|
||||
function bucketLabel(name: string, displayName?: string): string {
|
||||
if (name === "default") return "默认智能体";
|
||||
return displayName || name;
|
||||
}
|
||||
|
||||
function AgentMemoryPanel({ userId }: { userId: string }) {
|
||||
const { data: memory, isLoading } = useUserMemory(userId);
|
||||
// 默认选中"默认助手"("default" 对应全局记忆文件)
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>("default");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isV1 = memory?.version === "v1";
|
||||
|
||||
// 非 global、非 default 的其他智能体 bucket
|
||||
const otherBuckets = (memory?.buckets ?? []).filter(
|
||||
(b) => b.name !== "global" && b.name !== "default",
|
||||
);
|
||||
|
||||
// 当前选中的 bucket
|
||||
const currentBucket = (memory?.buckets ?? []).find(
|
||||
(b) => b.name === selectedAgent,
|
||||
);
|
||||
|
||||
// "default" 对应 agent=null(全局记忆文件),与 _resolve_agent_name 的哨兵逻辑一致
|
||||
const bucketAgentParam = selectedAgent === "default" ? null : selectedAgent;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 智能体下拉选择器 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm shrink-0">智能体:</span>
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="h-8 w-48" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 默认助手始终显示,对应全局记忆文件 */}
|
||||
<SelectItem value="default">默认助手</SelectItem>
|
||||
{otherBuckets.map((b) => (
|
||||
<SelectItem key={b.name} value={b.name}>
|
||||
{bucketLabel(b.name, b.display_name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isV1 && currentBucket && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{getEntries(currentBucket.data).length} 条
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前智能体记忆内容 */}
|
||||
{!currentBucket ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||
暂无记忆数据
|
||||
</div>
|
||||
) : isV1 ? (
|
||||
<V1BucketView
|
||||
data={currentBucket.data}
|
||||
agent={bucketAgentParam}
|
||||
userId={userId}
|
||||
/>
|
||||
) : (
|
||||
<MemoryEntryList
|
||||
entries={getEntries(currentBucket.data)}
|
||||
userId={userId}
|
||||
agent={bucketAgentParam}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AdminUserHub (main) ─────────────────────────────────────────────────────
|
||||
|
||||
export function AdminUserHub() {
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = userQuery.trim().toLowerCase();
|
||||
const list = users ?? [];
|
||||
return needle
|
||||
? list.filter((u) => u.email.toLowerCase().includes(needle))
|
||||
: list;
|
||||
}, [users, userQuery]);
|
||||
|
||||
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
|
||||
|
||||
function selectUser(id: string) {
|
||||
setSelectedUserId(id);
|
||||
setActiveTab("info");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理用户信息、审计历史问答、编辑用户记忆。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left: user list */}
|
||||
<div className="flex w-64 shrink-0 flex-col border-r">
|
||||
<div className="relative p-2">
|
||||
<SearchIcon className="text-muted-foreground absolute left-4 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
placeholder="搜索用户邮箱"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{usersLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!usersLoading && filteredUsers.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
无用户
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col p-1">
|
||||
{filteredUsers.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => selectUser(u.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
|
||||
u.id === selectedUserId
|
||||
? "bg-accent font-medium"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
{u.system_role === "admin" && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right: tabs */}
|
||||
{!selectedUserId ? (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||
从左侧选择一个用户
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||
className="flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="border-b px-4 pt-3 shrink-0">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="info">用户信息</TabsTrigger>
|
||||
<TabsTrigger value="audit">活动审计</TabsTrigger>
|
||||
<TabsTrigger value="memory">记忆管理</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="info" className="flex-1 overflow-auto p-0">
|
||||
{selectedUser && <UserInfoPanel user={selectedUser} />}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="flex min-h-0 flex-1 flex-col p-0"
|
||||
>
|
||||
<UserAuditPanel key={selectedUserId} userId={selectedUserId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="memory"
|
||||
className="flex min-h-0 flex-1 flex-col p-0"
|
||||
>
|
||||
<AgentMemoryPanel key={selectedUserId} userId={selectedUserId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-base" title={agent.name}>
|
||||
{agent.name}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -64,6 +64,111 @@ export function MessageList({
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
// (no user reply has been sent for them yet).
|
||||
const pendingClarificationIds = useMemo(() => {
|
||||
let lastHumanIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]?.type === "human") {
|
||||
lastHumanIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ids: string[] = [];
|
||||
for (let i = lastHumanIndex + 1; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg && msg.id && msg.type === "tool" && msg.name === "ask_clarification") {
|
||||
ids.push(msg.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [messages]);
|
||||
|
||||
const [clarificationSelections, setClarificationSelections] = useState<
|
||||
Map<string, ClarificationSelection>
|
||||
>(new Map());
|
||||
const [submittingClarification, setSubmittingClarification] = useState(false);
|
||||
|
||||
const handleClarificationSelectionChange = useCallback(
|
||||
(id: string, next: ClarificationSelection) => {
|
||||
setClarificationSelections((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(id, next);
|
||||
return map;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmitAllClarifications = useCallback(async () => {
|
||||
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
|
||||
return;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (let index = 0; index < pendingClarificationIds.length; index++) {
|
||||
const id = pendingClarificationIds[index]!;
|
||||
const message = messages.find((msg) => msg.id === id);
|
||||
const payload = message ? getClarificationPayload(message) : null;
|
||||
const selection = clarificationSelections.get(id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const options = (payload?.options ?? []).filter(
|
||||
(option) => typeof option.label === "string" && option.label.trim(),
|
||||
);
|
||||
const selectedLabels = selection.selectedIds
|
||||
.map(
|
||||
(sid) =>
|
||||
options.find((option) => (option.id || option.label) === sid)?.label,
|
||||
)
|
||||
.filter((label): label is string => Boolean(label && label.trim()));
|
||||
const trimmedCustom = selection.custom.trim();
|
||||
let part = "";
|
||||
if (selectedLabels.length > 0 && trimmedCustom) {
|
||||
part = `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
||||
} else if (selectedLabels.length > 0) {
|
||||
part = `我选择:${selectedLabels.join("、")}`;
|
||||
} else if (trimmedCustom) {
|
||||
part = trimmedCustom;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (pendingClarificationIds.length > 1) {
|
||||
const question = payload?.question?.trim();
|
||||
parts.push(
|
||||
question
|
||||
? `问题${index + 1}「${question}」:${part}`
|
||||
: `问题${index + 1}:${part}`,
|
||||
);
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return;
|
||||
}
|
||||
const combined =
|
||||
pendingClarificationIds.length > 1
|
||||
? `针对你提出的多个问题,依次回答:\n${parts.map((p) => `- ${p}`).join("\n")}`
|
||||
: parts[0]!;
|
||||
setSubmittingClarification(true);
|
||||
try {
|
||||
await onClarificationSubmit(combined);
|
||||
} finally {
|
||||
setSubmittingClarification(false);
|
||||
}
|
||||
}, [pendingClarificationIds, messages, clarificationSelections, onClarificationSubmit]);
|
||||
|
||||
const totalPending = pendingClarificationIds.length;
|
||||
const hasAnyClarificationAnswer = useMemo(() => {
|
||||
return pendingClarificationIds.some((id) => {
|
||||
const sel = clarificationSelections.get(id);
|
||||
return (sel?.selectedIds.length ?? 0) > 0 || (sel?.custom.trim() ?? "").length > 0;
|
||||
});
|
||||
}, [pendingClarificationIds, clarificationSelections]);
|
||||
|
||||
if (thread.isThreadLoading && messages.length === 0) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
@@ -89,14 +194,34 @@ export function MessageList({
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
const messageId = message.id ?? "";
|
||||
const pendingIndex = pendingClarificationIds.indexOf(messageId);
|
||||
const isPending = pendingIndex >= 0;
|
||||
const isLastPending =
|
||||
isPending && pendingIndex === totalPending - 1;
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
<ClarificationCard
|
||||
fallbackContent={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
message={message}
|
||||
onSubmit={onClarificationSubmit}
|
||||
rehypePlugins={rehypePlugins}
|
||||
isPending={isPending}
|
||||
pendingIndex={isPending ? pendingIndex : undefined}
|
||||
pendingCount={isPending ? totalPending : undefined}
|
||||
isLastPending={isLastPending}
|
||||
selection={
|
||||
clarificationSelections.get(messageId) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
}
|
||||
}
|
||||
onSelectionChange={(next) =>
|
||||
handleClarificationSelectionChange(messageId, next)
|
||||
}
|
||||
submitting={submittingClarification}
|
||||
canSubmit={hasAnyClarificationAnswer}
|
||||
onSubmitAll={handleSubmitAllClarifications}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -289,6 +414,11 @@ type ClarificationPayload = {
|
||||
allow_custom?: boolean;
|
||||
};
|
||||
|
||||
type ClarificationSelection = {
|
||||
selectedIds: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
export type SetupAgentApprovalPayload = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -447,19 +577,32 @@ function ClarificationCard({
|
||||
fallbackContent,
|
||||
isLoading,
|
||||
message,
|
||||
onSubmit,
|
||||
rehypePlugins,
|
||||
isPending,
|
||||
pendingIndex,
|
||||
pendingCount,
|
||||
isLastPending,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
submitting,
|
||||
canSubmit,
|
||||
onSubmitAll,
|
||||
}: {
|
||||
fallbackContent: string;
|
||||
isLoading: boolean;
|
||||
message: unknown;
|
||||
onSubmit?: (answer: string) => void | Promise<void>;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
isPending: boolean;
|
||||
pendingIndex?: number;
|
||||
pendingCount?: number;
|
||||
isLastPending: boolean;
|
||||
selection: ClarificationSelection;
|
||||
onSelectionChange: (next: ClarificationSelection) => void;
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onSubmitAll: () => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
@@ -479,27 +622,36 @@ function ClarificationCard({
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = false;
|
||||
const disabled = !isPending || submitting;
|
||||
const multiPending = (pendingCount ?? 0) > 1;
|
||||
|
||||
async function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed) {
|
||||
function toggleOption(optionId: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit?.(trimmed);
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
onSelectionChange({ ...selection, selectedIds: next });
|
||||
}
|
||||
|
||||
function updateCustom(value: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onSelectionChange({ ...selection, custom: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
需要你的确认
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<span>需要你的确认</span>
|
||||
{multiPending && pendingIndex !== undefined && pendingCount ? (
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]">
|
||||
{pendingIndex + 1} / {pendingCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{payload.context ? (
|
||||
<p className="text-muted-foreground text-sm leading-6">
|
||||
@@ -513,49 +665,84 @@ function ClarificationCard({
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id || option.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => void submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
||||
{option.description}
|
||||
</span>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可多选(再次点击取消选择)
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
const isSelected = selection.selectedIds.includes(optionId);
|
||||
return (
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleOption(optionId)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
{option.description ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-normal leading-5",
|
||||
isSelected
|
||||
? "text-primary-foreground/80"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allow_custom !== false ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={customAnswer}
|
||||
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
value={selection.custom}
|
||||
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||
placeholder={
|
||||
options.length > 0
|
||||
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||
}
|
||||
disabled={disabled}
|
||||
className="min-h-20 resize-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || !customAnswer.trim()}
|
||||
onClick={() => void submitAnswer(customAnswer)}
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
发送回答
|
||||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AppearanceSettingsPage() {
|
||||
<div className="text-sm font-medium">回答速度指示器</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
在生成回答时显示一个小胶囊,实时估算当前 token/s
|
||||
并用绿到红的渐变条直观反映快慢。默认关闭。
|
||||
并用绿到红的渐变条直观反映快慢。默认开启。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -471,7 +472,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||
function MemoryV1View({
|
||||
memory,
|
||||
}: {
|
||||
@@ -486,93 +487,112 @@ function MemoryV1View({
|
||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 说明横幅 */}
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||
</div>
|
||||
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
<Tabs defaultValue="facts">
|
||||
<TabsList>
|
||||
<TabsTrigger value="facts">
|
||||
事实记录
|
||||
{memory.facts.length > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||
{memory.facts.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profile">用户画像 & 历史</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
{/* 事实记录 Tab */}
|
||||
<TabsContent value="facts" className="mt-3">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 事实记录 (facts) */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{fact.sourceError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{fact.sourceError}
|
||||
</p>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 用户画像 & 历史摘要 Tab */}
|
||||
<TabsContent value="profile" className="mt-3 space-y-4">
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
CheckCircle2,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkills,
|
||||
@@ -81,6 +85,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -90,6 +96,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds });
|
||||
|
||||
@@ -140,10 +148,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const staleCount = staleVisible.length;
|
||||
const archivedCount = archivedVisible.length;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||
// Filter / search / tag changes may shrink the result set — pull the page
|
||||
// back into range without forcing a reset on every keystroke.
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
const pagedSkills = useMemo(
|
||||
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[filteredSkills, page],
|
||||
);
|
||||
|
||||
const handleCreateSkill = () => {
|
||||
onClose?.();
|
||||
router.push("/?mode=skill");
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
if (result.adopted.length > 0) {
|
||||
toast.success(
|
||||
t.settings.skills.rescanSkillsAdopted.replace(
|
||||
"{count}",
|
||||
String(result.adopted.length),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.info(t.settings.skills.rescanSkillsNoop);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<TrendingUpIcon className="size-4" />
|
||||
{t.settings.skills.goToEvolution}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleRescan()}
|
||||
disabled={
|
||||
reconcileMutation.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
title={t.settings.skills.rescanSkills}
|
||||
>
|
||||
{reconcileMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
{reconcileMutation.isPending
|
||||
? t.settings.skills.rescanSkillsRunning
|
||||
: t.settings.skills.rescanSkills}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -220,7 +276,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
)}
|
||||
{!isLoading && !error &&
|
||||
filteredSkills.length > 0 &&
|
||||
filteredSkills.map((skill) => (
|
||||
pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
@@ -228,6 +284,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
isAdmin={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" />
|
||||
{t.settings.skills.paginationPrev}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t.settings.skills.paginationPageOf
|
||||
.replace("{page}", String(page))
|
||||
.replace("{total}", String(totalPages))}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
{t.settings.skills.paginationNext}
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
|
||||
@@ -155,18 +155,10 @@ export function WorkspaceNavMenu() {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/users")}
|
||||
onClick={() => navigate("/page/workspace/admin")}
|
||||
>
|
||||
<UsersIcon />
|
||||
用户管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/memory")}
|
||||
>
|
||||
<BrainIcon />
|
||||
记忆管理
|
||||
用户中心
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@@ -179,16 +171,6 @@ export function WorkspaceNavMenu() {
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/user-activity")
|
||||
}
|
||||
>
|
||||
<HistoryIcon />
|
||||
用户活动审计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -446,7 +446,14 @@ export const enUS: Translations = {
|
||||
"Manage the configuration and enabled status of the agent skills.",
|
||||
createSkill: "Create skill",
|
||||
installSkill: "Install skill",
|
||||
rescanSkills: "Rescan skills directory",
|
||||
rescanSkillsRunning: "Scanning…",
|
||||
rescanSkillsAdopted: "Adopted {count} new skill(s)",
|
||||
rescanSkillsNoop: "No new skills found on disk",
|
||||
goToEvolution: "Skill evolution",
|
||||
paginationPrev: "Prev",
|
||||
paginationNext: "Next",
|
||||
paginationPageOf: "Page {page} of {total}",
|
||||
emptyTitle: "No agent skill yet",
|
||||
emptyDescription:
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||
|
||||
@@ -381,7 +381,14 @@ export interface Translations {
|
||||
description: string;
|
||||
createSkill: string;
|
||||
installSkill: string;
|
||||
rescanSkills: string;
|
||||
rescanSkillsRunning: string;
|
||||
rescanSkillsAdopted: string;
|
||||
rescanSkillsNoop: string;
|
||||
goToEvolution: string;
|
||||
paginationPrev: string;
|
||||
paginationNext: string;
|
||||
paginationPageOf: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyButton: string;
|
||||
|
||||
@@ -427,7 +427,14 @@ export const zhCN: Translations = {
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
createSkill: "新建技能",
|
||||
installSkill: "安装技能",
|
||||
rescanSkills: "重新扫描技能目录",
|
||||
rescanSkillsRunning: "扫描中…",
|
||||
rescanSkillsAdopted: "已收纳 {count} 个新技能",
|
||||
rescanSkillsNoop: "没有发现需要收纳的新技能",
|
||||
goToEvolution: "自我进化",
|
||||
paginationPrev: "上一页",
|
||||
paginationNext: "下一页",
|
||||
paginationPageOf: "第 {page} / {total} 页",
|
||||
emptyTitle: "还没有技能",
|
||||
emptyDescription:
|
||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||
|
||||
@@ -5,15 +5,15 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: false,
|
||||
streamSpeedMeterEnabled: true,
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
},
|
||||
};
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
@@ -29,7 +29,7 @@ export interface LocalSettings {
|
||||
display: {
|
||||
/**
|
||||
* Whether to render a small token/s speed meter while a streaming
|
||||
* AI reply is in flight. Default off; controlled by the user from
|
||||
* AI reply is in flight. Default on; controlled by the user from
|
||||
* the appearance settings page.
|
||||
*/
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
|
||||
@@ -135,6 +135,22 @@ export async function installSkillUpload(
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
|
||||
export interface ReconcileCustomSkillsResponse {
|
||||
adopted: string[];
|
||||
already_registered: string[];
|
||||
}
|
||||
|
||||
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to reconcile skills directory");
|
||||
}
|
||||
return response.json() as Promise<ReconcileCustomSkillsResponse>;
|
||||
}
|
||||
|
||||
export interface SetSkillPublishedResponse {
|
||||
name: string;
|
||||
owner_user_id: string | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
setSkillDetail,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
@@ -95,6 +96,16 @@ export function useInstallSkillUpload() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useReconcileCustomSkills() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => reconcileCustomSkills(),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillPublished() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminUserHub } from "@/components/workspace/admin/admin-user-hub";
|
||||
|
||||
export default function AdminUserHubPage() {
|
||||
return <AdminUserHub />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,14 +36,33 @@ export default function AgentChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_id } = useParams<{
|
||||
agent_id: string;
|
||||
}>();
|
||||
|
||||
const { agent } = useAgent(agent_id);
|
||||
// If ChatPage stashed an opening message for this agent, skip the landing
|
||||
// screen on first render so the user doesn't see a flash of the welcome UI
|
||||
// before the auto-sent message takes over.
|
||||
const hasPendingForThisAgent = () => {
|
||||
if (typeof window === "undefined" || !agent_id) return false;
|
||||
try {
|
||||
const raw = sessionStorage.getItem("pendingAgentMessage");
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.agentId === agent_id && !!parsed?.text;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(
|
||||
hasPendingForThisAgent,
|
||||
);
|
||||
|
||||
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
|
||||
const agentDisplayName =
|
||||
agent?.name ?? (isAgentLoading ? t.common.loading : "");
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
@@ -52,9 +71,10 @@ export default function AgentChatPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setHasStartedConversation(hasPendingForThisAgent());
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewThread, threadId, agent_id]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
@@ -100,6 +120,26 @@ export default function AgentChatPage() {
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
// ChatPage stashes the opening message in sessionStorage when the user picks
|
||||
// an agent on the landing screen, then redirects here. Replay it once so the
|
||||
// user doesn't have to re-type the question on the agent chat page.
|
||||
const pendingMessageHandled = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pendingMessageHandled.current) return;
|
||||
if (!agent_id || !isNewThread || hasStartedConversation) return;
|
||||
let pending: { agentId?: string; text?: string } | null = null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem("pendingAgentMessage");
|
||||
if (raw) pending = JSON.parse(raw);
|
||||
} catch {
|
||||
pending = null;
|
||||
}
|
||||
if (!pending || pending.agentId !== agent_id || !pending.text) return;
|
||||
pendingMessageHandled.current = true;
|
||||
sessionStorage.removeItem("pendingAgentMessage");
|
||||
handleSubmit({ text: pending.text, files: [] });
|
||||
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
@@ -133,8 +173,11 @@ export default function AgentChatPage() {
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">
|
||||
{agent?.name ?? agent_id}
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -180,8 +223,8 @@ export default function AgentChatPage() {
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
@@ -212,7 +255,7 @@ export default function AgentChatPage() {
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||
<AgentWelcome agent={agent} agentName={agentDisplayName} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
|
||||
@@ -193,6 +193,19 @@ function formatDate(value?: string | null): string {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "M月D日" badge prefixed to each run title so visually identical
|
||||
* names (the agent often emits the same title every run) become scannable.
|
||||
* Returns empty string for an invalid / missing date so the title falls back
|
||||
* to its bare form instead of rendering "Invalid Date".
|
||||
*/
|
||||
function formatScheduledDatePrefix(value?: string | null): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: string | null }): boolean {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
@@ -373,6 +386,7 @@ export default function PublicScheduledTaskPage() {
|
||||
const runTitle = stripTimestampSuffix(
|
||||
run.result?.title || formatDate(run.scheduled_for),
|
||||
);
|
||||
const datePrefix = formatScheduledDatePrefix(run.scheduled_for);
|
||||
const isDeletingThis =
|
||||
deleteRunMutation.isPending && deleteRunMutation.variables === run.id;
|
||||
return (
|
||||
@@ -398,13 +412,18 @@ export default function PublicScheduledTaskPage() {
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="text-sm leading-snug font-medium break-all"
|
||||
title={runTitle}
|
||||
title={datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
|
||||
>
|
||||
{datePrefix && (
|
||||
<span className="text-muted-foreground mr-1.5 text-xs font-normal tabular-nums">
|
||||
{datePrefix}
|
||||
</span>
|
||||
)}
|
||||
{runTitle}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[360px] break-all">
|
||||
{runTitle}
|
||||
{datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
|
||||
@@ -758,11 +758,11 @@ export default function ScheduledTasksPage() {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-2xl overflow-hidden">
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
@@ -798,15 +798,20 @@ export default function ScheduledTasksPage() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择执行智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
{agent.description ? ` — ${agent.description}` : ""}
|
||||
<span className="block truncate">
|
||||
{agent.name}
|
||||
{agent.description ? ` — ${agent.description}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminMemoryPage from "./AdminMemoryPage";
|
||||
import AdminUserHubPage from "./AdminUserHubPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||
@@ -206,18 +207,32 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Unified admin user hub */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
path="admin"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||
<AdminUserHubPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Legacy redirects */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/memory"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
@@ -230,30 +245,6 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminUserActivityPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/memory"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminMemoryPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
|
||||
@@ -181,9 +181,10 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
|
||||
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
|
||||
buckets: list[AdminMemoryBucket] = []
|
||||
try:
|
||||
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data=_read_v1_memory_bucket(user_id, None)))
|
||||
buckets.append(AdminMemoryBucket(name="default", display_name="默认助手", data=_read_v1_memory_bucket(user_id, None)))
|
||||
except Exception:
|
||||
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data={}))
|
||||
logger.exception("Failed to read V1 global memory for user %s", user_id)
|
||||
buckets.append(AdminMemoryBucket(name="default", display_name="默认助手", data={}))
|
||||
|
||||
for agent in owned_agents:
|
||||
agent_id: str = agent["id"]
|
||||
@@ -198,7 +199,7 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
|
||||
|
||||
# V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]}
|
||||
buckets = [
|
||||
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
|
||||
AdminMemoryBucket(name="default", display_name="默认助手", data=_read_memory_bucket(user_id, None))
|
||||
]
|
||||
for agent in owned_agents:
|
||||
agent_id = agent["id"]
|
||||
|
||||
@@ -39,7 +39,7 @@ router = APIRouter(prefix="/api", tags=["memory"])
|
||||
|
||||
|
||||
# 表示"全局桶 (无 agent)"的 agent 取值。
|
||||
_GLOBAL_AGENT_VALUES: frozenset[str] = frozenset({"", "global", "__global__"})
|
||||
_GLOBAL_AGENT_VALUES: frozenset[str] = frozenset({"", "global", "__global__", "default"})
|
||||
|
||||
|
||||
def _resolve_agent_name(agent: str | None) -> str | None:
|
||||
|
||||
@@ -161,6 +161,13 @@ class SkillUploadValidateResponse(BaseModel):
|
||||
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
|
||||
|
||||
|
||||
class SkillReconcileResponse(BaseModel):
|
||||
"""Response model for reconciling custom skills found on disk."""
|
||||
|
||||
adopted: list[str] = Field(default_factory=list, description="Skills found on disk that had no DB row; now owned by the caller")
|
||||
already_registered: list[str] = Field(default_factory=list, description="Skills found on disk that already had a DB row (unchanged)")
|
||||
|
||||
|
||||
class CustomSkillContentResponse(SkillResponse):
|
||||
content: str = Field(..., description="Raw SKILL.md content")
|
||||
|
||||
@@ -483,6 +490,54 @@ async def install_skill(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/reconcile-custom",
|
||||
response_model=SkillReconcileResponse,
|
||||
summary="Adopt Orphan Custom Skills",
|
||||
description=(
|
||||
"Scan the on-disk ``skills/custom/`` directory and register any skill that has a "
|
||||
"valid SKILL.md but no DB ownership row. Adopted skills are recorded as owned by "
|
||||
"the calling user with ``published=false``. Skips skills that already have a row "
|
||||
"(regardless of owner). Useful after manually unzipping skills onto the filesystem."
|
||||
),
|
||||
)
|
||||
async def reconcile_custom_skills(
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillReconcileResponse:
|
||||
try:
|
||||
user_id = _current_user_id(request)
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
custom_skills = [s for s in storage.load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
|
||||
|
||||
adopted: list[str] = []
|
||||
already: list[str] = []
|
||||
for skill in custom_skills:
|
||||
existing = await skill_store.get_any(skill.name)
|
||||
if existing is not None:
|
||||
already.append(skill.name)
|
||||
continue
|
||||
await skill_store.ensure_legacy(
|
||||
{"name": skill.name, "owner_user_id": user_id, "published": False}
|
||||
)
|
||||
try:
|
||||
from deerflow.skills.usage import mark_uploaded
|
||||
mark_uploaded(skill.name)
|
||||
except Exception:
|
||||
pass
|
||||
adopted.append(skill.name)
|
||||
|
||||
if adopted:
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return SkillReconcileResponse(adopted=adopted, already_registered=already)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to reconcile custom skills: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reconcile custom skills: {e}")
|
||||
|
||||
|
||||
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
||||
async def list_custom_skills(
|
||||
request: Request,
|
||||
|
||||
@@ -125,6 +125,8 @@ sandbox:
|
||||
|
||||
skills:
|
||||
path: D:\project\bj\deerflow-server\skills
|
||||
security_scan:
|
||||
enabled: false
|
||||
|
||||
agents_api:
|
||||
enabled: true
|
||||
@@ -144,7 +146,7 @@ checkpointer:
|
||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||
memory:
|
||||
enabled: true
|
||||
version: v2 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
|
||||
version: v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
|
||||
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
|
||||
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
|
||||
+11
@@ -464,11 +464,22 @@ You are {agent_name}, an internal super agent.
|
||||
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
|
||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
||||
- ❌ DO NOT cram several unrelated questions into one `question` string — split them into separate tool calls
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
|
||||
**Batching multiple independent questions:**
|
||||
- When the request leaves **several independent aspects** unclear (e.g. project type AND tech stack
|
||||
AND deployment target), call `ask_clarification` multiple times in the same turn — one call per
|
||||
aspect, each with its own focused `question` and `options`.
|
||||
- The frontend groups all clarification cards from one turn into a single submission, so the user
|
||||
answers them together in one reply. You will then receive ONE combined user message addressing
|
||||
every question in order — read it carefully and match answers back to the questions you asked.
|
||||
- If the next question only makes sense after the previous answer (sequential dependency), ask
|
||||
only the first one this turn and wait for the reply before asking the next.
|
||||
|
||||
**How to Use:**
|
||||
```python
|
||||
ask_clarification(
|
||||
|
||||
@@ -288,7 +288,7 @@ def build_memory_manager(
|
||||
|
||||
manager.initialize_all(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id or "default",
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
base_dir=base_dir,
|
||||
)
|
||||
|
||||
+7
-7
@@ -171,21 +171,21 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||
seconds = max(1, round(wait_ms / 1000))
|
||||
reason_text = "provider is busy" if reason == "busy" else "provider request failed temporarily"
|
||||
return f"LLM request retry {attempt}/{self.retry_max_attempts}: {reason_text}. Retrying in {seconds}s."
|
||||
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
|
||||
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
|
||||
|
||||
def _build_circuit_breaker_message(self) -> str:
|
||||
return "The configured LLM provider is currently unavailable due to continuous failures. Circuit breaker is engaged to protect the system. Please wait a moment before trying again."
|
||||
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
||||
|
||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||
detail = _extract_error_detail(exc)
|
||||
if reason == "quota":
|
||||
return "The configured LLM provider rejected the request because the account is out of quota, billing is unavailable, or usage is restricted. Please fix the provider account and try again."
|
||||
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
||||
if reason == "auth":
|
||||
return "The configured LLM provider rejected the request because authentication or access is invalid. Please check the provider credentials and try again."
|
||||
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
||||
if reason in {"busy", "transient"}:
|
||||
return "The configured LLM provider is temporarily unavailable after multiple retries. Please wait a moment and continue the conversation."
|
||||
return f"LLM request failed: {detail}"
|
||||
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
|
||||
return f"大模型因「{detail}」原因调用失败。"
|
||||
|
||||
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||
try:
|
||||
|
||||
+6
-3
@@ -117,15 +117,18 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _resolve_agent_id(self, runtime: Runtime | None) -> str:
|
||||
"""解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。"""
|
||||
def _resolve_agent_id(self, runtime: Runtime | None) -> str | None:
|
||||
"""解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。
|
||||
|
||||
无 agent 上下文时返回 None,使 V1 记忆存入全局文件(users/{id}/memory.json)。
|
||||
"""
|
||||
if self._agent_name:
|
||||
return self._agent_name
|
||||
if runtime is not None and getattr(runtime, "context", None):
|
||||
agent_id = runtime.context.get("agent_id")
|
||||
if agent_id:
|
||||
return str(agent_id)
|
||||
return "default"
|
||||
return None
|
||||
|
||||
# ---- wrap_model_call:Hindsight 动态召回注入 -------------------------
|
||||
|
||||
|
||||
+41
-2
@@ -105,8 +105,16 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
return prompt, user_msg
|
||||
|
||||
def _strip_think_tags(self, text: str) -> str:
|
||||
"""Remove <think>...</think> blocks emitted by reasoning models (e.g. minimax, DeepSeek-R1)."""
|
||||
return re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE).strip()
|
||||
"""Remove <think>...</think> blocks emitted by reasoning models (e.g. minimax, GLM, DeepSeek-R1).
|
||||
|
||||
Also handles malformed cases: an unclosed `<think>` (response truncated mid-reasoning)
|
||||
or a stray `</think>` (open tag eaten by upstream parsing) — both can otherwise leak
|
||||
raw reasoning into the title.
|
||||
"""
|
||||
text = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<think>[\s\S]*$", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^[\s\S]*?</think>", "", text, flags=re.IGNORECASE)
|
||||
return text.strip()
|
||||
|
||||
def _parse_title(self, content: object) -> str:
|
||||
"""Normalize model output into a clean title string."""
|
||||
@@ -146,6 +154,36 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
_, user_msg = self._build_title_prompt(state)
|
||||
return {"title": self._fallback_title(user_msg)}
|
||||
|
||||
def _force_disable_thinking(self, model: object) -> None:
|
||||
"""Force-disable thinking on the title model via `extra_body` overrides.
|
||||
|
||||
Title generation never needs reasoning. The model factory only emits
|
||||
disable params when the model config declares `when_thinking_disabled`
|
||||
or a matching `when_thinking_enabled` shape — many user-added reasoning
|
||||
models (e.g. GLM-4.5/5) only set `supports_thinking: true` and would
|
||||
otherwise keep thinking on, which is slow and leaks <think> tags into
|
||||
the title prompt response. Injecting both common OpenAI-compatible
|
||||
disable shapes covers Zhipu GLM (`thinking.type`) and Qwen/vLLM
|
||||
(`chat_template_kwargs.enable_thinking`); other providers ignore
|
||||
unknown body fields.
|
||||
"""
|
||||
if not hasattr(model, "extra_body"):
|
||||
return
|
||||
current = getattr(model, "extra_body", None) or {}
|
||||
merged_chat_template_kwargs = {
|
||||
**(current.get("chat_template_kwargs") or {}),
|
||||
"enable_thinking": False,
|
||||
}
|
||||
merged = {
|
||||
**current,
|
||||
"thinking": {**(current.get("thinking") or {}), "type": "disabled"},
|
||||
"chat_template_kwargs": merged_chat_template_kwargs,
|
||||
}
|
||||
try:
|
||||
model.extra_body = merged
|
||||
except Exception:
|
||||
logger.debug("Could not override extra_body to disable thinking on title model", exc_info=True)
|
||||
|
||||
async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None:
|
||||
"""Generate a title asynchronously and fall back locally on failure."""
|
||||
if not self._should_generate_title(state):
|
||||
@@ -162,6 +200,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
model = create_chat_model(name=config.model_name, **model_kwargs)
|
||||
else:
|
||||
model = create_chat_model(**model_kwargs)
|
||||
self._force_disable_thinking(model)
|
||||
response = await model.ainvoke(prompt, config=self._get_runnable_config())
|
||||
title = self._parse_title(response.content)
|
||||
if title:
|
||||
|
||||
@@ -6,6 +6,18 @@ from pydantic import BaseModel, Field
|
||||
from deerflow.config.runtime_paths import project_root, resolve_path
|
||||
|
||||
|
||||
class SecurityScanConfig(BaseModel):
|
||||
"""Toggle for the LLM-based skill security scanner."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"When false, skip the LLM security scan applied to skill installs, "
|
||||
"custom-skill edits, and rollbacks. The scan is then treated as 'allow'."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SkillsConfig(BaseModel):
|
||||
"""Configuration for skills system"""
|
||||
|
||||
@@ -21,6 +33,10 @@ class SkillsConfig(BaseModel):
|
||||
default="/mnt/skills",
|
||||
description="Path where skills are mounted in the sandbox container",
|
||||
)
|
||||
security_scan: SecurityScanConfig = Field(
|
||||
default_factory=SecurityScanConfig,
|
||||
description="LLM security-scan toggle for skill content.",
|
||||
)
|
||||
|
||||
def get_skills_path(self) -> Path:
|
||||
"""
|
||||
|
||||
+1
-1
@@ -713,7 +713,7 @@ class ScheduledTaskService:
|
||||
retry_count: int = 0,
|
||||
) -> None:
|
||||
"""Mark a run failed and reflect the outcome on the parent task."""
|
||||
result = _build_result(task, scheduled_for=scheduled_for, content=f"Scheduled task failed: {error}")
|
||||
result = _build_result(task, scheduled_for=scheduled_for, content=f"定时任务执行失败:{error}")
|
||||
await self.store.update_run(
|
||||
run_id,
|
||||
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
|
||||
|
||||
+6
-1
@@ -39,6 +39,12 @@ def _extract_json_object(raw: str) -> dict | None:
|
||||
|
||||
async def scan_skill_content(content: str, *, executable: bool = False, location: str = SKILL_MD_FILE, app_config: AppConfig | None = None) -> ScanResult:
|
||||
"""Screen skill content before it is written to disk."""
|
||||
config = app_config or get_app_config()
|
||||
# Operator-level kill switch: when ``skills.security_scan.enabled`` is false,
|
||||
# bypass the LLM round-trip entirely and let the content through.
|
||||
if not config.skills.security_scan.enabled:
|
||||
return ScanResult("allow", "Security scan disabled by config.")
|
||||
|
||||
rubric = (
|
||||
"You are a security reviewer for AI agent skills. "
|
||||
"Classify the content as allow, warn, or block. "
|
||||
@@ -49,7 +55,6 @@ async def scan_skill_content(content: str, *, executable: bool = False, location
|
||||
prompt = f"Location: {location}\nExecutable: {str(executable).lower()}\n\nReview this content:\n-----\n{content}\n-----"
|
||||
|
||||
try:
|
||||
config = app_config or get_app_config()
|
||||
# 安全审核复用 curator 的评审模型配置(skill_evolution.curator.model_name):
|
||||
# 二者用同一个模型。未配置时 model_name 为 None,create_chat_model 回退主模型。
|
||||
model_name = config.skill_evolution.curator.model_name
|
||||
|
||||
+20
-7
@@ -36,18 +36,31 @@ def ask_clarification_tool(
|
||||
- You're about to perform a potentially dangerous operation
|
||||
- You have a recommendation but need user approval
|
||||
|
||||
Batching multiple independent questions:
|
||||
- **You CAN and SHOULD call ask_clarification multiple times in the same turn** when the user's
|
||||
request leaves several **independent** aspects unclear (e.g. project type AND tech stack AND
|
||||
deployment target). Emit one tool call per aspect — do NOT cram several unrelated questions
|
||||
into one `question` string.
|
||||
- The frontend automatically groups all clarification cards in one turn into a single submission,
|
||||
so the user answers them together in one reply. You will then receive one combined user message
|
||||
addressing every question in order.
|
||||
- When the questions DEPEND on each other (the second only makes sense after the first is answered),
|
||||
ask only the first one and wait for the reply before asking the next.
|
||||
|
||||
Best practices:
|
||||
- Ask ONE clarification at a time for clarity
|
||||
- Be specific and clear in your question
|
||||
- Don't make assumptions when clarification is needed
|
||||
- For risky operations, ALWAYS ask for confirmation
|
||||
- After calling this tool, execution will be interrupted automatically
|
||||
- One question per ask_clarification call — keep each `question` focused on a single decision.
|
||||
- Be specific and clear in your question.
|
||||
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
|
||||
this lets the user multi-select in the UI instead of typing free text.
|
||||
- Don't make assumptions when clarification is needed.
|
||||
- For risky operations, ALWAYS ask for confirmation.
|
||||
- After calling this tool (or several in one turn), execution will be interrupted automatically.
|
||||
|
||||
Args:
|
||||
question: The clarification question to ask the user. Be specific and clear.
|
||||
question: The clarification question to ask the user. Focused on a single decision; be specific and clear.
|
||||
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
|
||||
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
|
||||
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from.
|
||||
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from; the UI supports multi-select.
|
||||
"""
|
||||
# This is a placeholder implementation
|
||||
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for the ``POST /api/skills/reconcile-custom`` adoption flow.
|
||||
|
||||
Covers the scenario where a user drops a skill directory onto disk by hand
|
||||
(bypassing the upload API) and then calls the reconcile route so it shows up
|
||||
in the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.routers import skills as skills_router
|
||||
from deerflow.persistence.skills import MemorySkillStore
|
||||
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
||||
|
||||
|
||||
def _write_skill_on_disk(root: Path, name: str, description: str = "from disk") -> None:
|
||||
"""Drop a valid SKILL.md into ``<root>/custom/<name>/`` like a manual unzip would."""
|
||||
skill_dir = root / "custom" / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _fake_request(user_id: str | None = "user-a") -> SimpleNamespace:
|
||||
user = SimpleNamespace(id=user_id, system_role="user") if user_id else None
|
||||
return SimpleNamespace(state=SimpleNamespace(user=user))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_root(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "skills"
|
||||
(root / "public").mkdir(parents=True)
|
||||
(root / "custom").mkdir(parents=True)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_storage(
|
||||
skills_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> LocalSkillStorage:
|
||||
"""Force the router to use a LocalSkillStorage rooted at ``skills_root``."""
|
||||
storage = LocalSkillStorage(host_path=str(skills_root))
|
||||
monkeypatch.setattr(
|
||||
skills_router, "get_or_new_skill_storage", lambda **_: storage
|
||||
)
|
||||
|
||||
async def _noop_refresh() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
skills_router, "refresh_skills_system_prompt_cache_async", _noop_refresh
|
||||
)
|
||||
return storage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_adopts_orphan_custom_skill(
|
||||
skills_root: Path, patched_storage: LocalSkillStorage
|
||||
):
|
||||
_write_skill_on_disk(skills_root, "manually-dropped")
|
||||
store = MemorySkillStore()
|
||||
|
||||
result = await skills_router.reconcile_custom_skills(
|
||||
request=_fake_request("user-a"), config=None, skill_store=store
|
||||
)
|
||||
|
||||
assert result.adopted == ["manually-dropped"]
|
||||
assert result.already_registered == []
|
||||
|
||||
record = await store.get_any("manually-dropped")
|
||||
assert record is not None
|
||||
assert record["owner_user_id"] == "user-a"
|
||||
assert record["published"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_skips_already_registered(
|
||||
skills_root: Path, patched_storage: LocalSkillStorage
|
||||
):
|
||||
_write_skill_on_disk(skills_root, "already-known")
|
||||
store = MemorySkillStore()
|
||||
# Pre-existing ownership row — should be left alone.
|
||||
await store.create(
|
||||
{"name": "already-known", "owner_user_id": "user-b", "published": True}
|
||||
)
|
||||
|
||||
result = await skills_router.reconcile_custom_skills(
|
||||
request=_fake_request("user-a"), config=None, skill_store=store
|
||||
)
|
||||
|
||||
assert result.adopted == []
|
||||
assert result.already_registered == ["already-known"]
|
||||
|
||||
# Ownership must not be overwritten.
|
||||
record = await store.get_any("already-known")
|
||||
assert record is not None
|
||||
assert record["owner_user_id"] == "user-b"
|
||||
assert record["published"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_mixed_adopts_only_orphans(
|
||||
skills_root: Path, patched_storage: LocalSkillStorage
|
||||
):
|
||||
_write_skill_on_disk(skills_root, "orphan-one")
|
||||
_write_skill_on_disk(skills_root, "orphan-two")
|
||||
_write_skill_on_disk(skills_root, "registered")
|
||||
store = MemorySkillStore()
|
||||
await store.create(
|
||||
{"name": "registered", "owner_user_id": "user-a", "published": False}
|
||||
)
|
||||
|
||||
result = await skills_router.reconcile_custom_skills(
|
||||
request=_fake_request("user-a"), config=None, skill_store=store
|
||||
)
|
||||
|
||||
assert sorted(result.adopted) == ["orphan-one", "orphan-two"]
|
||||
assert result.already_registered == ["registered"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_with_empty_custom_dir_is_noop(
|
||||
skills_root: Path, patched_storage: LocalSkillStorage
|
||||
):
|
||||
store = MemorySkillStore()
|
||||
|
||||
result = await skills_router.reconcile_custom_skills(
|
||||
request=_fake_request("user-a"), config=None, skill_store=store
|
||||
)
|
||||
|
||||
assert result.adopted == []
|
||||
assert result.already_registered == []
|
||||
@@ -109,6 +109,8 @@ title:
|
||||
Assistant: {assistant_msg}
|
||||
skills:
|
||||
path: /app/skills
|
||||
security_scan:
|
||||
enabled: false
|
||||
agents_api:
|
||||
enabled: true
|
||||
database:
|
||||
@@ -138,6 +140,7 @@ ai_writing:
|
||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||
memory:
|
||||
enabled: true
|
||||
version: v1
|
||||
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
builtin:
|
||||
|
||||
Reference in new issue
Block a user