This commit is contained in:
fengxiangdebaozi committed 2026-05-23 17:23:22 +08:00
commit 3987ee7ee3
48 files changed
+1858 -334

No files matched your search

@@ -105,6 +105,12 @@ const menuItems: MenuItem[] = [
icon: List, icon: List,
path: "/page/strategy/light-app/notes", path: "/page/strategy/light-app/notes",
}, },
{
id: "light-app-note-qa",
label: "笔记问答",
icon: MessageSquareCode,
path: "/page/strategy/light-app/note-qa",
},
], ],
}, },
{ id: "light-app-square", label: "应用广场", icon: Layout }, { id: "light-app-square", label: "应用广场", icon: Layout },
@@ -29,7 +29,7 @@ import {
} from "@/core/admin/hooks"; } from "@/core/admin/hooks";
import { useAllUsers } from "@/core/auth/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; const raw = (data as { entries?: unknown }).entries;
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0); 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, data,
agent, agent,
userId, userId,
@@ -319,7 +319,7 @@ function V1BucketView({
// ── Memory entry list with inline editing ────────────────────────────────── // ── Memory entry list with inline editing ──────────────────────────────────
function MemoryEntryList({ export function MemoryEntryList({
entries, entries,
userId, userId,
agent, agent,
@@ -490,7 +490,7 @@ function MemoryEntryList({
// ── Right panel: memory tabs for a selected user ─────────────────────────── // ── 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 { data: memory, isLoading } = useUserMemory(userId);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null); 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"> <Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2"> <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"> <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" /> <BotIcon className="h-5 w-5" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<CardTitle className="truncate text-base"> <CardTitle className="truncate text-base" title={agent.name}>
{agent.name} {agent.name}
</CardTitle> </CardTitle>
{agent.model && ( {agent.model && (
@@ -463,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> { interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
result?: string; result?: string | Record<string, unknown>;
} }
type CoTStep = CoTReasoningStep | CoTToolCallStep; type CoTStep = CoTReasoningStep | CoTToolCallStep;
@@ -495,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
}; };
const toolCallId = tool_call.id; const toolCallId = tool_call.id;
if (toolCallId) { if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages); const toolCallResult = findToolCallResult(
if (toolCallResult) { toolCallId,
try { messages,
const json = JSON.parse(toolCallResult); tool_call.name,
step.result = json; );
} catch { if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
step.result = JSON.parse(toolCallResult) as Record<
string,
unknown
>;
} catch {
step.result = toolCallResult;
}
} else {
step.result = toolCallResult; step.result = toolCallResult;
} }
} }
@@ -1,6 +1,6 @@
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 { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
Conversation, Conversation,
@@ -64,6 +64,111 @@ export function MessageList({
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = thread.messages; 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) { if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />; return <MessageListSkeleton />;
} }
@@ -89,14 +194,34 @@ export function MessageList({
} else if (group.type === "assistant:clarification") { } else if (group.type === "assistant:clarification") {
const message = group.messages[0]; const message = group.messages[0];
if (message && hasContent(message)) { if (message && hasContent(message)) {
const messageId = message.id ?? "";
const pendingIndex = pendingClarificationIds.indexOf(messageId);
const isPending = pendingIndex >= 0;
const isLastPending =
isPending && pendingIndex === totalPending - 1;
return ( return (
<div key={group.id} className="w-full"> <div key={group.id} className="w-full">
<ClarificationCard <ClarificationCard
fallbackContent={extractContentFromMessage(message)} fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading} isLoading={thread.isLoading}
message={message} message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins} 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 <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -289,6 +414,11 @@ type ClarificationPayload = {
allow_custom?: boolean; allow_custom?: boolean;
}; };
type ClarificationSelection = {
selectedIds: string[];
custom: string;
};
export type SetupAgentApprovalPayload = { export type SetupAgentApprovalPayload = {
name?: string; name?: string;
description?: string; description?: string;
@@ -447,19 +577,32 @@ function ClarificationCard({
fallbackContent, fallbackContent,
isLoading, isLoading,
message, message,
onSubmit,
rehypePlugins, rehypePlugins,
isPending,
pendingIndex,
pendingCount,
isLastPending,
selection,
onSelectionChange,
submitting,
canSubmit,
onSubmitAll,
}: { }: {
fallbackContent: string; fallbackContent: string;
isLoading: boolean; isLoading: boolean;
message: unknown; message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>; 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 payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const options = useMemo( 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) { function toggleOption(optionId: string) {
const trimmed = answer.trim(); if (disabled) {
if (!trimmed) {
return; return;
} }
setSubmitting(true); const next = selection.selectedIds.includes(optionId)
try { ? selection.selectedIds.filter((id) => id !== optionId)
await onSubmit?.(trimmed); : [...selection.selectedIds, optionId];
setSubmitted(true); onSelectionChange({ ...selection, selectedIds: next });
} finally { }
setSubmitting(false);
function updateCustom(value: string) {
if (disabled) {
return;
} }
onSelectionChange({ ...selection, custom: value });
} }
return ( return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm"> <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="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> </div>
{payload.context ? ( {payload.context ? (
<p className="text-muted-foreground text-sm leading-6"> <p className="text-muted-foreground text-sm leading-6">
@@ -513,49 +665,84 @@ function ClarificationCard({
{options.length > 0 ? ( {options.length > 0 ? (
<div className="mt-4 grid gap-2"> <div className="mt-4 grid gap-2">
{options.map((option) => ( <div className="text-muted-foreground text-xs">
<Button 可多选(再次点击取消选择)
key={option.id || option.label} </div>
type="button" {options.map((option) => {
variant="outline" const optionId = option.id || option.label;
className="h-auto justify-start whitespace-normal px-3 py-2 text-left" const isSelected = selection.selectedIds.includes(optionId);
disabled={disabled} return (
onClick={() => void submitAnswer(`我选择:${option.label}`)} <Button
> key={optionId}
<span className="flex min-w-0 flex-col items-start gap-1"> type="button"
<span>{option.label}</span> variant={isSelected ? "default" : "outline"}
{option.description ? ( className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
<span className="text-muted-foreground text-xs font-normal leading-5"> disabled={disabled}
{option.description} onClick={() => toggleOption(optionId)}
</span> >
<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} ) : null}
</span> </Button>
</Button> );
))} })}
</div> </div>
) : null} ) : null}
{payload.allow_custom !== false ? ( {payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4 flex flex-col gap-2">
<Textarea <Textarea
value={customAnswer} value={selection.custom}
onChange={(event) => setCustomAnswer(event.currentTarget.value)} onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答" placeholder={
options.length > 0
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled} disabled={disabled}
className="min-h-20 resize-none" 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 <Button
type="button" type="button"
size="sm" size="sm"
disabled={disabled || !customAnswer.trim()} disabled={!canSubmit || submitting}
onClick={() => void submitAnswer(customAnswer)} onClick={() => void onSubmitAll()}
> >
<SendIcon className="h-4 w-4" /> <SendIcon className="h-4 w-4" />
发送回答 {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
</Button> </Button>
</div> </div>
</div> ) : (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
)
) : null} ) : null}
</div> </div>
); );
@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
ChevronDown,
Download, Download,
FileJson, FileJson,
FileText, FileText,
@@ -63,6 +64,15 @@ export function RecentChatList() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
};
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
useParams<{ useParams<{
thread_id: string; thread_id: string;
@@ -161,12 +171,24 @@ export function RecentChatList() {
return ( return (
<> <>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"> <SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase"> <SidebarGroupLabel
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
? t.sidebar.recentChats onClick={toggleCollapsed}
: t.sidebar.demoChats} >
<span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0"> <SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[800px] opacity-100"
}`}
>
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-0.5"> <div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => { {visibleThreads.map((thread) => {
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
function openSession(
session: AIWritingSessionSummary,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在 AI 写作页面,直接通过自定义事件通知面板
window.dispatchEvent(
new CustomEvent("ai-writing:open-session", { detail: session }),
);
} else {
// 不在该页面,把 session 存入 sessionStorage 后跳转
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
router.push(AI_WRITING_PATH);
}
}
export function RecentWritingList() {
const router = useRouter();
const pathname = usePathname();
const { sessions, loading, refresh } = useAIWritingSessions();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
if (!loading && sessions.length === 0) return null;
const isOnPage = pathname === AI_WRITING_PATH;
return (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -114,7 +114,7 @@ export function AppearanceSettingsPage() {
<div className="text-sm font-medium">回答速度指示器</div> <div className="text-sm font-medium">回答速度指示器</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed"> <p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
在生成回答时显示一个小胶囊,实时估算当前 token/s 在生成回答时显示一个小胶囊,实时估算当前 token/s
并用绿到红的渐变条直观反映快慢。默认关闭。 并用绿到红的渐变条直观反映快慢。默认开启。
</p> </p>
</div> </div>
<Switch <Switch
@@ -22,6 +22,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -471,7 +472,7 @@ const CATEGORY_COLORS: Record<string, string> = {
correction: "bg-red-100 text-red-700", correction: "bg-red-100 text-red-700",
}; };
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */ /** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
function MemoryV1View({ function MemoryV1View({
memory, memory,
}: { }: {
@@ -486,93 +487,112 @@ function MemoryV1View({
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v); const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
return ( 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"> <div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。 V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div> </div>
{/* 用户画像 (userContext) */} <Tabs defaultValue="facts">
<div className="rounded-lg border p-4 space-y-2"> <TabsList>
<h3 className="text-base font-medium">用户画像</h3> <TabsTrigger value="facts">
{ctxEntries.length === 0 ? ( 事实记录
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p> {memory.facts.length > 0 && (
) : ( <span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
<dl className="space-y-2"> {memory.facts.length}
{ctxEntries.map(([key, value]) => ( </span>
<div key={key} className="text-sm"> )}
<dt className="font-medium text-muted-foreground mb-0.5"> </TabsTrigger>
{CTX_LABELS[key] ?? key} <TabsTrigger value="profile">用户画像 &amp; 历史</TabsTrigger>
</dt> </TabsList>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
</div>
))}
</dl>
)}
</div>
{/* 历史摘要 (history) */} {/* 事实记录 Tab */}
<div className="rounded-lg border p-4 space-y-2"> <TabsContent value="facts" className="mt-3">
<h3 className="text-base font-medium">历史摘要</h3> <div className="rounded-lg border p-4 space-y-3">
{historyEntries.length === 0 ? ( <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">暂无历史摘要。</p> <h3 className="text-base font-medium">事实记录</h3>
) : ( <span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
<dl className="space-y-2"> </div>
{historyEntries.map(([key, value]) => ( {memory.facts.length === 0 ? (
<div key={key} className="text-sm"> <div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
<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> </div>
))} ) : (
</dl> <div className="space-y-2">
)} {memory.facts.map((fact) => (
</div> <div key={fact.id} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center gap-2">
{/* 事实记录 (facts) */} <span
<div className="rounded-lg border p-4 space-y-3"> className={`rounded px-1.5 py-0.5 text-xs font-medium ${
<div className="flex items-center justify-between"> CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
<h3 className="text-base font-medium">事实记录</h3> }`}
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span> >
</div> {fact.category}
{memory.facts.length === 0 ? ( </span>
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground"> <div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
暂无事实记录,对话后系统会自动提取。 <div
</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>
<div key={fact.id} className="rounded-md border p-3 space-y-1.5"> <span className="text-xs text-muted-foreground shrink-0">
<div className="flex items-center gap-2"> {Math.round(fact.confidence * 100)}%
<span </span>
className={`rounded px-1.5 py-0.5 text-xs font-medium ${ </div>
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700" <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.category} 避免:{fact.sourceError}
</span> </p>
<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> </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> </div>
)} </TabsContent>
</div>
{/* 用户画像 & 历史摘要 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> </div>
); );
} }
@@ -4,6 +4,8 @@ import {
AlertCircle, AlertCircle,
ArchiveIcon, ArchiveIcon,
CheckCircle2, CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon, ClockIcon,
FileArchive, FileArchive,
GlobeIcon, GlobeIcon,
@@ -11,6 +13,7 @@ import {
LockIcon, LockIcon,
PinIcon, PinIcon,
PinOffIcon, PinOffIcon,
RefreshCwIcon,
SparklesIcon, SparklesIcon,
TagsIcon, TagsIcon,
Trash2Icon, Trash2Icon,
@@ -18,7 +21,7 @@ import {
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; 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 { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { import {
useDeleteCustomSkill, useDeleteCustomSkill,
useInstallSkillUpload, useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillPinned, useSetSkillPinned,
useSetSkillPublished, useSetSkillPublished,
useSkills, useSkills,
@@ -81,6 +85,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
); );
} }
const PAGE_SIZE = 10;
function SkillSettingsList({ onClose }: { onClose?: () => void }) { function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -90,6 +96,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false); const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); 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. // Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds }); const { skills, isLoading, error } = useSkills({ search, tagIds });
@@ -140,10 +148,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const staleCount = staleVisible.length; const staleCount = staleVisible.length;
const archivedCount = archivedVisible.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 = () => { const handleCreateSkill = () => {
onClose?.(); onClose?.();
router.push("/?mode=skill"); 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 ( return (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="flex flex-wrap items-center justify-between gap-3">
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<TrendingUpIcon className="size-4" /> <TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution} {t.settings.skills.goToEvolution}
</Button> </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 <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -220,7 +276,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
)} )}
{!isLoading && !error && {!isLoading && !error &&
filteredSkills.length > 0 && filteredSkills.length > 0 &&
filteredSkills.map((skill) => ( pagedSkills.map((skill) => (
<SkillRow <SkillRow
key={skill.name} key={skill.name}
skill={skill} skill={skill}
@@ -228,6 +284,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
isAdmin={isAdmin} 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 <InstallSkillDialog
open={installOpen} open={installOpen}
onClose={() => setInstallOpen(false)} onClose={() => setInstallOpen(false)}
@@ -155,18 +155,10 @@ export function WorkspaceNavMenu() {
)} )}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/users")} onClick={() => navigate("/page/workspace/admin")}
> >
<UsersIcon /> <UsersIcon />
用户管理 用户中心
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/memory")}
>
<BrainIcon />
记忆管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && ( {isAdmin && (
@@ -179,16 +171,6 @@ export function WorkspaceNavMenu() {
定时任务管理 定时任务管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/user-activity")
}
>
<HistoryIcon />
用户活动审计
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")} onClick={() => navigate("/page/workspace/admin/tags")}
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu"; import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} /> <WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />} {isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent> </SidebarContent>
{!compactChrome && ( {!compactChrome && (
<SidebarFooter> <SidebarFooter>
@@ -446,7 +446,14 @@ export const enUS: Translations = {
"Manage the configuration and enabled status of the agent skills.", "Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill", createSkill: "Create skill",
installSkill: "Install 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", goToEvolution: "Skill evolution",
paginationPrev: "Prev",
paginationNext: "Next",
paginationPageOf: "Page {page} of {total}",
emptyTitle: "No agent skill yet", emptyTitle: "No agent skill yet",
emptyDescription: emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.", "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; description: string;
createSkill: string; createSkill: string;
installSkill: string; installSkill: string;
rescanSkills: string;
rescanSkillsRunning: string;
rescanSkillsAdopted: string;
rescanSkillsNoop: string;
goToEvolution: string; goToEvolution: string;
paginationPrev: string;
paginationNext: string;
paginationPageOf: string;
emptyTitle: string; emptyTitle: string;
emptyDescription: string; emptyDescription: string;
emptyButton: string; emptyButton: string;
@@ -427,7 +427,14 @@ export const zhCN: Translations = {
description: "管理 Agent Skill 配置和启用状态。", description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能", createSkill: "新建技能",
installSkill: "安装技能", installSkill: "安装技能",
rescanSkills: "重新扫描技能目录",
rescanSkillsRunning: "扫描中…",
rescanSkillsAdopted: "已收纳 {count} 个新技能",
rescanSkillsNoop: "没有发现需要收纳的新技能",
goToEvolution: "自我进化", goToEvolution: "自我进化",
paginationPrev: "上一页",
paginationNext: "下一页",
paginationPageOf: "第 {page} / {total} 页",
emptyTitle: "还没有技能", emptyTitle: "还没有技能",
emptyDescription: emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。", "将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const layers = collectNestedRecords(record); const layers = collectNestedRecords(record);
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
let title = let title =
pickFromLayers(layers, TITLE_ALIASES) ?? pickFromLayers(layers, TITLE_ALIASES) ??
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 }); pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
if (!title && indexLabel) {
title = `结果 ${indexLabel}`;
}
let content = let content =
pickFromLayers(layers, CONTENT_ALIASES) ?? pickFromLayers(layers, CONTENT_ALIASES) ??
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true }); pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
title = pickShortTitleFallback(layers, reserved); title = pickShortTitleFallback(layers, reserved);
} }
if (!title && !content && !url) { // 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
if (!title && !content && !url && !indexLabel) {
return null; return null;
} }
+81 -15
View File
@@ -1,5 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk"; import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { hasSearchResultPayload } from "@/core/messages/search-results";
interface GenericMessageGroup<T = string> { interface GenericMessageGroup<T = string> {
type: T; type: T;
id: string | undefined; id: string | undefined;
@@ -141,21 +143,63 @@ export function groupMessages<T>(
} }
export function extractTextFromMessage(message: Message) { export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") { const payload = extractToolResultPayload(message);
return ( if (typeof payload === "string") {
splitInlineReasoningFromAIMessage(message)?.content ?? return payload;
message.content.trim()
);
} }
if (Array.isArray(message.content)) { if (payload && typeof payload === "object") {
return message.content try {
.map((content) => (content.type === "text" ? content.text : "")) return JSON.stringify(payload);
.join("\n") } catch {
.trim(); return "";
}
} }
return ""; return "";
} }
/**
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
*/
export function extractToolResultPayload(
message: Message,
): string | Record<string, unknown> | undefined {
const { content } = message;
if (typeof content === "string") {
const trimmed = content.trim();
return trimmed || undefined;
}
if (content && typeof content === "object" && !Array.isArray(content)) {
return content as Record<string, unknown>;
}
if (Array.isArray(content)) {
const textParts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") {
continue;
}
const block = part as Record<string, unknown>;
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
continue;
}
if (block.type === "json" && block.json && typeof block.json === "object") {
return block.json as Record<string, unknown>;
}
if (hasSearchResultPayload(block)) {
return block;
}
}
const joined = textParts.join("\n").trim();
return joined || undefined;
}
return undefined;
}
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g; const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) { function splitInlineReasoning(content: string) {
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
return false; return false;
} }
export function findToolCallResult(toolCallId: string, messages: Message[]) { export function findToolCallResult(
toolCallId: string,
messages: Message[],
toolName?: string,
): string | Record<string, unknown> | undefined {
for (const message of messages) { for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) { if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
const content = extractTextFromMessage(message); continue;
if (content) { }
return content; const payload = extractToolResultPayload(message);
if (payload !== undefined) {
return payload;
}
}
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName) {
for (const message of messages) {
if (message.type !== "tool" || message.name !== toolName) {
continue;
}
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
continue;
}
const payload = extractToolResultPayload(message);
if (payload !== undefined && hasSearchResultPayload(payload)) {
return payload;
} }
} }
} }
return undefined; return undefined;
} }
+8 -8
View File
@@ -5,15 +5,15 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
enabled: true, enabled: true,
}, },
display: { display: {
streamSpeedMeterEnabled: false, streamSpeedMeterEnabled: true,
}, },
context: { context: {
model_name: undefined, model_name: undefined,
mode: undefined, mode: undefined,
reasoning_effort: undefined, reasoning_effort: undefined,
writing_mode: false, writing_mode: false,
}, },
}; };
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.";
@@ -29,7 +29,7 @@ export interface LocalSettings {
display: { display: {
/** /**
* Whether to render a small token/s speed meter while a streaming * 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. * the appearance settings page.
*/ */
streamSpeedMeterEnabled: boolean; streamSpeedMeterEnabled: boolean;
+16
View File
@@ -135,6 +135,22 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>; 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 { export interface SetSkillPublishedResponse {
name: string; name: string;
owner_user_id: string | null; owner_user_id: string | null;
+11
View File
@@ -11,6 +11,7 @@ import {
getSkill, getSkill,
getSkillContent, getSkillContent,
installSkillUpload, installSkillUpload,
reconcileCustomSkills,
setSkillDetail, setSkillDetail,
setSkillPinned, setSkillPinned,
setSkillPublished, 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() { export function useSetSkillPublished() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps { interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void; onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean; disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
hideAdvancedFields?: boolean;
} }
interface ModelOption { interface ModelOption {
@@ -31,7 +33,14 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50'; 'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { const DEFAULT_KEYWORD_COUNT = 6;
const DEFAULT_AUDIENCE = '普通读者';
export function AIWritingForm({
onSubmit,
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState(''); const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis'); const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
e.preventDefault(); e.preventDefault();
if (!intent.trim()) return; if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6)); const keywordCount = hideAdvancedFields
? DEFAULT_KEYWORD_COUNT
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({ onSubmit({
userIntent: intent.trim(), userIntent: intent.trim(),
articleType, articleType,
targetAudience: audience.trim() || '普通读者', targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount, wordCountTarget: wordCount,
keywordCount, keywordCount,
writingMode: strictMode ? 'strict' : 'loose', writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined, modelName: modelName || undefined,
}); });
} }
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div> </div>
</div> </div>
<div className="space-y-1.5"> {!hideAdvancedFields && (
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label> <>
<input <div className="space-y-1.5">
type="number" <label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
min={1} <input
max={10} type="number"
step={1} min={1}
className={INPUT_CLASS} max={10}
placeholder="6" step={1}
value={keywordCountInput} className={INPUT_CLASS}
onChange={(e) => setKeywordCountInput(e.target.value)} placeholder="6"
onBlur={handleKeywordCountBlur} value={keywordCountInput}
disabled={disabled} onChange={(e) => setKeywordCountInput(e.target.value)}
/> onBlur={handleKeywordCountBlur}
<p className="text-[11px] text-muted-foreground/70"> disabled={disabled}
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。 />
</p> <p className="text-[11px] text-muted-foreground/70">
</div> 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2"> <div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input <input
type="checkbox" type="checkbox"
id="strict-mode" id="strict-mode"
checked={strictMode} checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)} onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled} disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer" className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/> />
<label htmlFor="strict-mode" className="cursor-pointer select-none"> <label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span> <span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed"> <p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。 勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p> </p>
</label> </label>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label> <label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input <input
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…" placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience} value={audience}
onChange={(e) => setAudience(e.target.value)} onChange={(e) => setAudience(e.target.value)}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
</>
)}
{models.length > 0 && ( {models.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions'; import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABELS: Record<string, { label: string; color: string }> = { export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' }, in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' }, draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' }, review_ready: { label: '审核', color: 'text-purple-500' },
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
error: { label: '错误', color: 'text-destructive' }, error: { label: '错误', color: 'text-destructive' },
}; };
function formatRelative(isoStr: string): string { export function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime(); const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000); const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚'; if (mins < 1) return '刚刚';
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
} }
interface SessionItemProps { export interface SessionItemProps {
session: AIWritingSessionSummary; session: AIWritingSessionSummary;
isSelected: boolean; isSelected: boolean;
onSelect: () => void; onSelect: () => void;
@@ -33,7 +33,7 @@ interface SessionItemProps {
onRename: (title: string) => void; onRename: (title: string) => void;
} }
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) { export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title); const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
error: '出错了', error: '出错了',
}; };
export function AIWritingPanel() { export function AIWritingPanel({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext(); const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
reset(); reset();
}, [reset]); }, [reset]);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
const handleSelectHistoryRef = useRef(handleSelectHistory);
useEffect(() => {
handleSelectHistoryRef.current = handleSelectHistory;
}, [handleSelectHistory]);
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
useEffect(() => {
const raw = sessionStorage.getItem('ai-writing:pending-session');
if (!raw) return;
sessionStorage.removeItem('ai-writing:pending-session');
try {
const session = JSON.parse(raw) as AIWritingSessionSummary;
handleSelectHistoryRef.current(session);
} catch { /* ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
useEffect(() => {
function onOpenSession(e: Event) {
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
if (session) handleSelectHistoryRef.current(session);
}
window.addEventListener('ai-writing:open-session', onOpenSession);
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []);
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ──────────────────────────────── // ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。 // ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。 // 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto"> <div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */} {/* 未开始时显示表单 */}
{isIdle && ( {isIdle && (
<AIWritingForm onSubmit={startWriting} /> <AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
/>
)} )}
{/* 意图解析:流式 → 结果卡片承接。 {/* 意图解析:流式 → 结果卡片承接。
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} /> <IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)} )}
{state.parsedIntent && !state.intentStreamingText && ( {state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} /> <IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)} )}
{/* 运行中或完成后显示时间线 */} {/* 运行中或完成后显示时间线 */}
@@ -484,20 +524,26 @@ export function AIWritingPanel() {
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType} {articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
</span> </span>
</ReqRow> </ReqRow>
<ReqRow label="目标读者"> {!hideAdvancedFields && (
<span className="text-muted-foreground">{state.request.targetAudience}</span> <ReqRow label="目标读者">
</ReqRow> <span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
)}
<ReqRow label="目标字数"> <ReqRow label="目标字数">
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span> <span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
</ReqRow> </ReqRow>
<ReqRow label="检索关键词数量"> {!hideAdvancedFields && (
<span className="text-muted-foreground">{state.request.keywordCount} 个</span> <>
</ReqRow> <ReqRow label="检索关键词数量">
<ReqRow label="写作模式"> <span className="text-muted-foreground">{state.request.keywordCount} 个</span>
<span className="text-muted-foreground"> </ReqRow>
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'} <ReqRow label="写作模式">
</span> <span className="text-muted-foreground">
</ReqRow> {state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
</>
)}
{state.request.modelName && ( {state.request.modelName && (
<ReqRow label="写作模型"> <ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span> <span className="text-muted-foreground">{state.request.modelName}</span>
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps { interface IntentResultCardProps {
intent: ParsedIntent; intent: ParsedIntent;
articleTypes: ArticleType[]; articleTypes: ArticleType[];
hideTargetAudience?: boolean;
} }
/** /**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项: * 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。 * 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/ */
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) { export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道") // article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel = const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType; articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
</div> </div>
<div className="px-3 py-2.5 space-y-1.5 text-xs"> <div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} /> <Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} /> {!hideTargetAudience && (
<Row label="目标读者" value={intent.targetAudience} />
)}
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} /> <Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && ( {intent.refinedIntent && (
<Row label="精炼意图" value={intent.refinedIntent} multiline /> <Row label="精炼意图" value={intent.refinedIntent} multiline />
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65) // Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() { export default function AIWritingPage({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
return ( return (
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
> >
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */} {/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel /> <AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -0,0 +1,5 @@
import { AdminUserHub } from "@/components/workspace/admin/admin-user-hub";
export default function AdminUserHubPage() {
return <AdminUserHub />;
}
+53 -10
View File
@@ -3,7 +3,7 @@
import { BotIcon, PlusSquare } from "lucide-react"; import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base"; 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 type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -36,14 +36,33 @@ export default function AgentChatPage() {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter(); const router = useRouter();
const { agent_id } = useParams<{ const { agent_id } = useParams<{
agent_id: string; 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 } = const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat(); useThreadChat();
@@ -52,9 +71,10 @@ export default function AgentChatPage() {
useEffect(() => { useEffect(() => {
if (isNewThread) { if (isNewThread) {
setHasStartedConversation(false); setHasStartedConversation(hasPendingForThisAgent());
} }
}, [isNewThread, threadId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewThread, threadId, agent_id]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
@@ -100,6 +120,26 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id], [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( const handleClarificationSubmit = useCallback(
(answer: string) => { (answer: string) => {
setHasStartedConversation(true); setHasStartedConversation(true);
@@ -133,8 +173,11 @@ export default function AgentChatPage() {
{/* Agent badge */} {/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1"> <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" /> <BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium"> <span
{agent?.name ?? agent_id} className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
{agentDisplayName}
</span> </span>
</div> </div>
@@ -180,8 +223,8 @@ export default function AgentChatPage() {
"relative w-full", "relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]", isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState 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"> <div className="absolute -top-4 right-0 left-0 z-0">
@@ -212,7 +255,7 @@ export default function AgentChatPage() {
context={settings.context} context={settings.context}
extraHeader={ extraHeader={
isLandingState && ( isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} /> <AgentWelcome agent={agent} agentName={agentDisplayName} />
) )
} }
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { BookOpen, Plus, Send } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
export default function LightAppNoteQAPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [question, setQuestion] = useState("");
const handleSubmit = () => {
if (!question.trim()) return;
setQuestion("");
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden",
isDark ? "bg-slate-950" : "bg-white",
)}
>
{/* 背景光晕 */}
<div
className="pointer-events-none absolute inset-0"
style={{
background: isDark
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
}}
/>
{/* 主内容 */}
<div className="relative flex h-full flex-col items-center justify-center -translate-y-10">
<div className="flex w-full max-w-[520px] flex-col items-center gap-4 px-6">
{/* 笔记图标 */}
<div
className={cn(
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
)}
>
<BookOpen className="h-6 w-6" />
</div>
{/* 笔记标题 */}
<h1
className={cn(
"text-xl font-semibold tracking-tight",
isDark ? "text-slate-100" : "text-slate-900",
)}
>
笔记01
</h1>
{/* 新增来源按钮 */}
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
isDark
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
)}
>
<Plus className="h-3.5 w-3.5" />
新增来源
</button>
{/* 问答输入框 */}
<div
className={cn(
"mt-3 flex w-full items-center gap-3 rounded-full px-5 py-3.5",
isDark
? "bg-slate-800 ring-1 ring-white/10"
: "bg-white shadow-sm ring-1 ring-black/5",
)}
>
<Plus
className={cn(
"h-4 w-4 shrink-0",
isDark ? "text-slate-500" : "text-slate-400",
)}
/>
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
placeholder="开始问答"
className={cn(
"min-w-0 flex-1 bg-transparent text-sm outline-none",
isDark
? "text-slate-200 placeholder:text-slate-500"
: "text-slate-700 placeholder:text-slate-400",
)}
/>
{question.trim() && (
<button
type="button"
onClick={handleSubmit}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-sky-500 text-white hover:bg-sky-400"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)}
aria-label="发送"
>
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -193,6 +193,19 @@ function formatDate(value?: string | null): string {
return value ? new Date(value).toLocaleString() : "暂无"; 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 { function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: string | null }): boolean {
const name = file.name.toLowerCase(); const name = file.name.toLowerCase();
return ( return (
@@ -373,6 +386,7 @@ export default function PublicScheduledTaskPage() {
const runTitle = stripTimestampSuffix( const runTitle = stripTimestampSuffix(
run.result?.title || formatDate(run.scheduled_for), run.result?.title || formatDate(run.scheduled_for),
); );
const datePrefix = formatScheduledDatePrefix(run.scheduled_for);
const isDeletingThis = const isDeletingThis =
deleteRunMutation.isPending && deleteRunMutation.variables === run.id; deleteRunMutation.isPending && deleteRunMutation.variables === run.id;
return ( return (
@@ -398,13 +412,18 @@ export default function PublicScheduledTaskPage() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className="text-sm leading-snug font-medium break-all" 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} {runTitle}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="max-w-[360px] break-all"> <TooltipContent side="right" className="max-w-[360px] break-all">
{runTitle} {datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
+12 -7
View File
@@ -758,11 +758,11 @@ export default function ScheduledTasksPage() {
</Dialog> </Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}> <Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl overflow-hidden">
<DialogHeader> <DialogHeader className="min-w-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle> <DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex min-w-0 flex-col gap-3">
<Input <Input
value={form.name} value={form.name}
onChange={(event) => 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="选择执行智能体" /> <SelectValue placeholder="选择执行智能体" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem> <SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => ( {agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}> <SelectItem key={agent.id} value={agent.id}>
{agent.name} <span className="block truncate">
{agent.description ? ` — ${agent.description}` : ""} {agent.name}
{agent.description ? ` — ${agent.description}` : ""}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
export default function StrategyRoutes() { export default function StrategyRoutes() {
return ( return (
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
{/* 轻应用 */} {/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} /> <Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} /> <Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */} {/* 对话 */}
<Route path="chats" element={<ChatsPage />} /> <Route path="chats" element={<ChatsPage />} />
+19 -28
View File
@@ -29,6 +29,7 @@ import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage"; import AdminMemoryPage from "./AdminMemoryPage";
import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage"; import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage"; import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
@@ -182,7 +183,7 @@ export default function WorkspaceRoutes({
path="ai-writing" path="ai-writing"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<AIWritingPage /> <AIWritingPage hideAdvancedFields />
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
@@ -206,18 +207,32 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
{/* Unified admin user hub */}
<Route <Route
path="admin/users" path="admin"
element={ element={
getStoredAuth()?.system_role === "admin" ? ( getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps} pageSidebarHidden>
<UsersPage /> <AdminUserHubPage />
</WorkspaceLayout> </WorkspaceLayout>
) : ( ) : (
<Navigate to="/page/workspace/chats/new" replace /> <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 <Route
path="admin/scheduled-tasks" path="admin/scheduled-tasks"
element={ 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 <Route
path="admin/tags" path="admin/tags"
element={ element={
@@ -181,9 +181,10 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history} # V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
buckets: list[AdminMemoryBucket] = [] buckets: list[AdminMemoryBucket] = []
try: 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: 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: for agent in owned_agents:
agent_id: str = agent["id"] 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: [...]} # V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]}
buckets = [ 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: for agent in owned_agents:
agent_id = agent["id"] agent_id = agent["id"]
@@ -39,7 +39,7 @@ router = APIRouter(prefix="/api", tags=["memory"])
# 表示"全局桶 (无 agent)"的 agent 取值。 # 表示"全局桶 (无 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: 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") 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): class CustomSkillContentResponse(SkillResponse):
content: str = Field(..., description="Raw SKILL.md content") 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)}") 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") @router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
async def list_custom_skills( async def list_custom_skills(
request: Request, request: Request,
+3 -1
View File
@@ -125,6 +125,8 @@ sandbox:
skills: skills:
path: D:\project\bj\deerflow-server\skills path: D:\project\bj\deerflow-server\skills
security_scan:
enabled: false
agents_api: agents_api:
enabled: true enabled: true
@@ -147,7 +149,7 @@ checkpointer:
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory: memory:
enabled: true enabled: true
version: v2 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取) version: v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight) # 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名) provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个 # builtin 始终启用,这里只决定 external 选哪个
@@ -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 skip clarification for "efficiency" - accuracy matters more than speed
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask - ❌ DO NOT make assumptions when information is missing - ALWAYS ask
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first - ❌ 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 - ✅ 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 - ✅ 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 - ✅ After calling ask_clarification, execution will be interrupted automatically
- ✅ Wait for user response - do NOT continue with assumptions - ✅ 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:** **How to Use:**
```python ```python
ask_clarification( ask_clarification(
@@ -288,7 +288,7 @@ def build_memory_manager(
manager.initialize_all( manager.initialize_all(
user_id=user_id, user_id=user_id,
agent_id=agent_id or "default", agent_id=agent_id,
thread_id=thread_id, thread_id=thread_id,
base_dir=base_dir, base_dir=base_dir,
) )
@@ -171,21 +171,21 @@ 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 = "provider is busy" if reason == "busy" else "provider request failed temporarily" reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
return f"LLM request retry {attempt}/{self.retry_max_attempts}: {reason_text}. Retrying in {seconds}s." 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 "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: def _build_user_message(self, exc: BaseException, reason: str) -> str:
detail = _extract_error_detail(exc) detail = _extract_error_detail(exc)
if reason == "quota": 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": 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"}: 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 "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
return f"LLM request failed: {detail}" return f"大模型因「{detail}」原因调用失败。"
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:
@@ -117,15 +117,18 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
except RuntimeError: except RuntimeError:
return None return None
def _resolve_agent_id(self, runtime: Runtime | None) -> str: def _resolve_agent_id(self, runtime: Runtime | None) -> str | None:
"""解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。""" """解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。
无 agent 上下文时返回 None,使 V1 记忆存入全局文件(users/{id}/memory.json)。
"""
if self._agent_name: if self._agent_name:
return self._agent_name return self._agent_name
if runtime is not None and getattr(runtime, "context", None): if runtime is not None and getattr(runtime, "context", None):
agent_id = runtime.context.get("agent_id") agent_id = runtime.context.get("agent_id")
if agent_id: if agent_id:
return str(agent_id) return str(agent_id)
return "default" return None
# ---- wrap_model_call:Hindsight 动态召回注入 ------------------------- # ---- wrap_model_call:Hindsight 动态召回注入 -------------------------
@@ -105,8 +105,16 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return prompt, user_msg return prompt, user_msg
def _strip_think_tags(self, text: str) -> str: def _strip_think_tags(self, text: str) -> str:
"""Remove <think>...</think> blocks emitted by reasoning models (e.g. minimax, DeepSeek-R1).""" """Remove <think>...</think> blocks emitted by reasoning models (e.g. minimax, GLM, DeepSeek-R1).
return re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE).strip()
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: def _parse_title(self, content: object) -> str:
"""Normalize model output into a clean title string.""" """Normalize model output into a clean title string."""
@@ -146,6 +154,36 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
_, user_msg = self._build_title_prompt(state) _, user_msg = self._build_title_prompt(state)
return {"title": self._fallback_title(user_msg)} 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: async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None:
"""Generate a title asynchronously and fall back locally on failure.""" """Generate a title asynchronously and fall back locally on failure."""
if not self._should_generate_title(state): 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) model = create_chat_model(name=config.model_name, **model_kwargs)
else: else:
model = create_chat_model(**model_kwargs) model = create_chat_model(**model_kwargs)
self._force_disable_thinking(model)
response = await model.ainvoke(prompt, config=self._get_runnable_config()) response = await model.ainvoke(prompt, config=self._get_runnable_config())
title = self._parse_title(response.content) title = self._parse_title(response.content)
if title: if title:
@@ -6,6 +6,18 @@ from pydantic import BaseModel, Field
from deerflow.config.runtime_paths import project_root, resolve_path 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): class SkillsConfig(BaseModel):
"""Configuration for skills system""" """Configuration for skills system"""
@@ -21,6 +33,10 @@ class SkillsConfig(BaseModel):
default="/mnt/skills", default="/mnt/skills",
description="Path where skills are mounted in the sandbox container", 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: def get_skills_path(self) -> Path:
""" """
@@ -713,7 +713,7 @@ class ScheduledTaskService:
retry_count: int = 0, retry_count: int = 0,
) -> None: ) -> None:
"""Mark a run failed and reflect the outcome on the parent task.""" """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( await self.store.update_run(
run_id, run_id,
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count}, {"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
@@ -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: 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.""" """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 = ( rubric = (
"You are a security reviewer for AI agent skills. " "You are a security reviewer for AI agent skills. "
"Classify the content as allow, warn, or block. " "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-----" prompt = f"Location: {location}\nExecutable: {str(executable).lower()}\n\nReview this content:\n-----\n{content}\n-----"
try: try:
config = app_config or get_app_config()
# 安全审核复用 curator 的评审模型配置(skill_evolution.curator.model_name): # 安全审核复用 curator 的评审模型配置(skill_evolution.curator.model_name):
# 二者用同一个模型。未配置时 model_name 为 None,create_chat_model 回退主模型。 # 二者用同一个模型。未配置时 model_name 为 None,create_chat_model 回退主模型。
model_name = config.skill_evolution.curator.model_name model_name = config.skill_evolution.curator.model_name
@@ -36,18 +36,31 @@ def ask_clarification_tool(
- You're about to perform a potentially dangerous operation - You're about to perform a potentially dangerous operation
- You have a recommendation but need user approval - 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: Best practices:
- Ask ONE clarification at a time for clarity - One question per ask_clarification call — keep each `question` focused on a single decision.
- Be specific and clear in your question - Be specific and clear in your question.
- Don't make assumptions when clarification is needed - Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
- For risky operations, ALWAYS ask for confirmation this lets the user multi-select in the UI instead of typing free text.
- After calling this tool, execution will be interrupted automatically - 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: 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). 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. 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 # This is a placeholder implementation
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call # 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 == []
+3
View File
@@ -109,6 +109,8 @@ title:
Assistant: {assistant_msg} Assistant: {assistant_msg}
skills: skills:
path: /app/skills path: /app/skills
security_scan:
enabled: false
agents_api: agents_api:
enabled: true enabled: true
database: database:
@@ -138,6 +140,7 @@ ai_writing:
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory: memory:
enabled: true enabled: true
version: v1
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名) provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个 # builtin 始终启用,这里只决定 external 选哪个
builtin: builtin: