This commit is contained in:
董宏凯 committed 2026-05-22 21:41:52 +08:00
commit d6ff00226a
34 files changed
+1322 -234

No files matched your search

@@ -29,7 +29,7 @@ import {
} from "@/core/admin/hooks";
import { useAllUsers } from "@/core/auth/hooks";
function getEntries(data: Record<string, unknown>): string[] {
export function getEntries(data: Record<string, unknown>): string[] {
const raw = (data as { entries?: unknown }).entries;
if (!Array.isArray(raw)) return [];
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
@@ -251,7 +251,7 @@ function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string
);
}
function V1BucketView({
export function V1BucketView({
data,
agent,
userId,
@@ -319,7 +319,7 @@ function V1BucketView({
// ── Memory entry list with inline editing ──────────────────────────────────
function MemoryEntryList({
export function MemoryEntryList({
entries,
userId,
agent,
@@ -490,7 +490,7 @@ function MemoryEntryList({
// ── Right panel: memory tabs for a selected user ───────────────────────────
function UserMemoryPanel({ userId }: { userId: string }) {
export function UserMemoryPanel({ userId }: { userId: string }) {
const { data: memory, isLoading } = useUserMemory(userId);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
@@ -0,0 +1,419 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { AdminMessage } from "@/core/admin/api";
import {
useUserMemory,
useUserThreadMessages,
useUserThreads,
} from "@/core/admin/hooks";
import type { UserListItem } from "@/core/auth/api";
import { useAllUsers } from "@/core/auth/hooks";
import { cn } from "@/lib/utils";
import {
getEntries,
MemoryEntryList,
V1BucketView,
} from "./admin-memory-page";
// ── Helpers (shared with AdminUserActivityPage) ─────────────────────────────
function extractText(content: unknown): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const text = (block as Record<string, unknown>).text;
if (typeof text === "string") return text;
}
return "";
})
.join("");
}
if (typeof content === "object") {
const inner = (content as Record<string, unknown>).content;
if (inner !== undefined) return extractText(inner);
}
return "";
}
function roleOf(message: AdminMessage): "user" | "assistant" | null {
const type = (message.type ?? "").toLowerCase();
if (type === "human") return "user";
if (type === "ai" || type === "aimessagechunk") return "assistant";
return null;
}
function fmtTime(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
// ── UserInfoPanel ───────────────────────────────────────────────────────────
function UserInfoPanel({ user }: { user: UserListItem }) {
return (
<div className="p-6">
<dl className="grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 text-sm">
<dt className="text-muted-foreground font-medium">邮箱</dt>
<dd className="break-all">{user.email}</dd>
<dt className="text-muted-foreground font-medium">角色</dt>
<dd>
{user.system_role === "admin" ? (
<Badge variant="default">管理员</Badge>
) : (
<Badge variant="secondary">普通用户</Badge>
)}
</dd>
<dt className="text-muted-foreground font-medium">Skills</dt>
<dd className="tabular-nums">{user.skill_count}</dd>
<dt className="text-muted-foreground font-medium">Agents</dt>
<dd className="tabular-nums">{user.agent_count}</dd>
<dt className="text-muted-foreground font-medium">User ID</dt>
<dd className="font-mono text-xs text-muted-foreground break-all">{user.id}</dd>
</dl>
</div>
);
}
// ── MessageRow / MemoryBucketView (audit sub-components) ────────────────────
function MessageRow({ message }: { message: AdminMessage }) {
const role = roleOf(message);
if (!role) return null;
const text = extractText(message.content).trim();
if (!text) return null;
return (
<div
className={cn(
"flex flex-col gap-1",
role === "user" ? "items-start" : "items-end",
)}
>
<span className="text-muted-foreground text-xs">
{role === "user" ? "用户提问" : "AI 回答"}
</span>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
)}
>
{text}
</div>
</div>
);
}
// ── UserAuditPanel ──────────────────────────────────────────────────────────
function UserAuditPanel({ userId }: { userId: string }) {
const [threadId, setThreadId] = useState<string | null>(null);
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
userId,
threadId,
);
return (
<div className="flex min-h-0 flex-1">
{/* Thread list */}
<div className="flex w-60 shrink-0 flex-col border-r">
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
会话列表{threads ? `(${threads.length})` : ""}
</div>
<ScrollArea className="min-h-0 flex-1">
{threadsLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{!threadsLoading && (threads?.length ?? 0) === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
该用户暂无会话
</div>
)}
<div className="flex flex-col p-1">
{(threads ?? []).map((thread) => (
<button
key={thread.thread_id}
type="button"
onClick={() => setThreadId(thread.thread_id)}
className={cn(
"flex flex-col gap-0.5 rounded-md px-2 py-2 text-left",
thread.thread_id === threadId ? "bg-accent" : "hover:bg-muted",
)}
>
<span className="truncate text-sm">
{thread.display_name || "未命名会话"}
</span>
<span className="text-muted-foreground text-xs">
{fmtTime(thread.updated_at)}
</span>
</button>
))}
</div>
</ScrollArea>
</div>
{/* Messages */}
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{!threadId && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
<MessageSquareIcon className="size-6" />
请从左侧选择一个会话
</div>
)}
{threadId && messagesLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{threadId && !messagesLoading && (
<div className="flex flex-col gap-3">
{(messages ?? []).map((m, i) => (
<MessageRow key={m.id ?? i} message={m} />
))}
{(messages ?? []).every((m) => !roleOf(m)) && (
<div className="text-muted-foreground py-12 text-center text-sm">
该会话暂无问答记录
</div>
)}
</div>
)}
</div>
</ScrollArea>
</div>
);
}
// ── AgentMemoryPanel ─────────────────────────────────────────────────────────
function bucketLabel(name: string, displayName?: string): string {
if (name === "default") return "默认智能体";
return displayName || name;
}
function AgentMemoryPanel({ userId }: { userId: string }) {
const { data: memory, isLoading } = useUserMemory(userId);
// 默认选中"默认助手"("default" 对应全局记忆文件)
const [selectedAgent, setSelectedAgent] = useState<string>("default");
if (isLoading) {
return (
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中…
</div>
);
}
const isV1 = memory?.version === "v1";
// 非 global、非 default 的其他智能体 bucket
const otherBuckets = (memory?.buckets ?? []).filter(
(b) => b.name !== "global" && b.name !== "default",
);
// 当前选中的 bucket
const currentBucket = (memory?.buckets ?? []).find(
(b) => b.name === selectedAgent,
);
// "default" 对应 agent=null(全局记忆文件),与 _resolve_agent_name 的哨兵逻辑一致
const bucketAgentParam = selectedAgent === "default" ? null : selectedAgent;
return (
<div className="flex min-h-0 flex-1 flex-col">
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 p-4">
{/* 智能体下拉选择器 */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm shrink-0">智能体:</span>
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="h-8 w-48" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* 默认助手始终显示,对应全局记忆文件 */}
<SelectItem value="default">默认助手</SelectItem>
{otherBuckets.map((b) => (
<SelectItem key={b.name} value={b.name}>
{bucketLabel(b.name, b.display_name)}
</SelectItem>
))}
</SelectContent>
</Select>
{!isV1 && currentBucket && (
<span className="text-muted-foreground text-xs">
{getEntries(currentBucket.data).length} 条
</span>
)}
</div>
{/* 当前智能体记忆内容 */}
{!currentBucket ? (
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
暂无记忆数据
</div>
) : isV1 ? (
<V1BucketView
data={currentBucket.data}
agent={bucketAgentParam}
userId={userId}
/>
) : (
<MemoryEntryList
entries={getEntries(currentBucket.data)}
userId={userId}
agent={bucketAgentParam}
/>
)}
</div>
</ScrollArea>
</div>
);
}
// ── AdminUserHub (main) ─────────────────────────────────────────────────────
export function AdminUserHub() {
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
return needle
? list.filter((u) => u.email.toLowerCase().includes(needle))
: list;
}, [users, userQuery]);
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
function selectUser(id: string) {
setSelectedUserId(id);
setActiveTab("info");
}
return (
<div className="flex size-full flex-col">
<div className="border-b px-6 py-4">
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
管理用户信息、审计历史问答、编辑用户记忆。
</p>
</div>
<div className="flex min-h-0 flex-1">
{/* Left: user list */}
<div className="flex w-64 shrink-0 flex-col border-r">
<div className="relative p-2">
<SearchIcon className="text-muted-foreground absolute left-4 top-1/2 size-4 -translate-y-1/2" />
<Input
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
placeholder="搜索用户邮箱"
className="pl-8"
/>
</div>
<ScrollArea className="min-h-0 flex-1">
{usersLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{!usersLoading && filteredUsers.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
无用户
</div>
)}
<div className="flex flex-col p-1">
{filteredUsers.map((u) => (
<button
key={u.id}
type="button"
onClick={() => selectUser(u.id)}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
u.id === selectedUserId
? "bg-accent font-medium"
: "hover:bg-muted",
)}
>
<UserIcon className="text-muted-foreground size-4 shrink-0" />
<span className="flex-1 truncate">{u.email}</span>
{u.system_role === "admin" && (
<Badge variant="outline" className="shrink-0 text-xs">
管理员
</Badge>
)}
</button>
))}
</div>
</ScrollArea>
</div>
{/* Right: tabs */}
{!selectedUserId ? (
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
从左侧选择一个用户
</div>
) : (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
className="flex min-w-0 flex-1 flex-col overflow-hidden"
>
<div className="border-b px-4 pt-3 shrink-0">
<TabsList variant="line">
<TabsTrigger value="info">用户信息</TabsTrigger>
<TabsTrigger value="audit">活动审计</TabsTrigger>
<TabsTrigger value="memory">记忆管理</TabsTrigger>
</TabsList>
</div>
<TabsContent value="info" className="flex-1 overflow-auto p-0">
{selectedUser && <UserInfoPanel user={selectedUser} />}
</TabsContent>
<TabsContent
value="audit"
className="flex min-h-0 flex-1 flex-col p-0"
>
<UserAuditPanel key={selectedUserId} userId={selectedUserId} />
</TabsContent>
<TabsContent
value="memory"
className="flex min-h-0 flex-1 flex-col p-0"
>
<AgentMemoryPanel key={selectedUserId} userId={selectedUserId} />
</TabsContent>
</Tabs>
)}
</div>
</div>
);
}
@@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base" title={agent.name}>
{agent.name}
</CardTitle>
{agent.model && (
@@ -1,6 +1,6 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
Conversation,
@@ -64,6 +64,111 @@ export function MessageList({
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
const pendingClarificationIds = useMemo(() => {
let lastHumanIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.type === "human") {
lastHumanIndex = i;
break;
}
}
const ids: string[] = [];
for (let i = lastHumanIndex + 1; i < messages.length; i++) {
const msg = messages[i];
if (msg && msg.id && msg.type === "tool" && msg.name === "ask_clarification") {
ids.push(msg.id);
}
}
return ids;
}, [messages]);
const [clarificationSelections, setClarificationSelections] = useState<
Map<string, ClarificationSelection>
>(new Map());
const [submittingClarification, setSubmittingClarification] = useState(false);
const handleClarificationSelectionChange = useCallback(
(id: string, next: ClarificationSelection) => {
setClarificationSelections((prev) => {
const map = new Map(prev);
map.set(id, next);
return map;
});
},
[],
);
const handleSubmitAllClarifications = useCallback(async () => {
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
return;
}
const parts: string[] = [];
for (let index = 0; index < pendingClarificationIds.length; index++) {
const id = pendingClarificationIds[index]!;
const message = messages.find((msg) => msg.id === id);
const payload = message ? getClarificationPayload(message) : null;
const selection = clarificationSelections.get(id) ?? {
selectedIds: [],
custom: "",
};
const options = (payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
);
const selectedLabels = selection.selectedIds
.map(
(sid) =>
options.find((option) => (option.id || option.label) === sid)?.label,
)
.filter((label): label is string => Boolean(label && label.trim()));
const trimmedCustom = selection.custom.trim();
let part = "";
if (selectedLabels.length > 0 && trimmedCustom) {
part = `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
} else if (selectedLabels.length > 0) {
part = `我选择:${selectedLabels.join("、")}`;
} else if (trimmedCustom) {
part = trimmedCustom;
} else {
continue;
}
if (pendingClarificationIds.length > 1) {
const question = payload?.question?.trim();
parts.push(
question
? `问题${index + 1}「${question}」:${part}`
: `问题${index + 1}:${part}`,
);
} else {
parts.push(part);
}
}
if (parts.length === 0) {
return;
}
const combined =
pendingClarificationIds.length > 1
? `针对你提出的多个问题,依次回答:\n${parts.map((p) => `- ${p}`).join("\n")}`
: parts[0]!;
setSubmittingClarification(true);
try {
await onClarificationSubmit(combined);
} finally {
setSubmittingClarification(false);
}
}, [pendingClarificationIds, messages, clarificationSelections, onClarificationSubmit]);
const totalPending = pendingClarificationIds.length;
const hasAnyClarificationAnswer = useMemo(() => {
return pendingClarificationIds.some((id) => {
const sel = clarificationSelections.get(id);
return (sel?.selectedIds.length ?? 0) > 0 || (sel?.custom.trim() ?? "").length > 0;
});
}, [pendingClarificationIds, clarificationSelections]);
if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />;
}
@@ -89,14 +194,34 @@ export function MessageList({
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
const messageId = message.id ?? "";
const pendingIndex = pendingClarificationIds.indexOf(messageId);
const isPending = pendingIndex >= 0;
const isLastPending =
isPending && pendingIndex === totalPending - 1;
return (
<div key={group.id} className="w-full">
<ClarificationCard
fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading}
message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins}
isPending={isPending}
pendingIndex={isPending ? pendingIndex : undefined}
pendingCount={isPending ? totalPending : undefined}
isLastPending={isLastPending}
selection={
clarificationSelections.get(messageId) ?? {
selectedIds: [],
custom: "",
}
}
onSelectionChange={(next) =>
handleClarificationSelectionChange(messageId, next)
}
submitting={submittingClarification}
canSubmit={hasAnyClarificationAnswer}
onSubmitAll={handleSubmitAllClarifications}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -289,6 +414,11 @@ type ClarificationPayload = {
allow_custom?: boolean;
};
type ClarificationSelection = {
selectedIds: string[];
custom: string;
};
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
@@ -447,19 +577,32 @@ function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
isPending,
pendingIndex,
pendingCount,
isLastPending,
selection,
onSelectionChange,
submitting,
canSubmit,
onSubmitAll,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
isPending: boolean;
pendingIndex?: number;
pendingCount?: number;
isLastPending: boolean;
selection: ClarificationSelection;
onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean;
canSubmit: boolean;
onSubmitAll: () => void | Promise<void>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const options = useMemo(
() =>
@@ -479,27 +622,36 @@ function ClarificationCard({
);
}
const disabled = false;
const disabled = !isPending || submitting;
const multiPending = (pendingCount ?? 0) > 1;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed) {
function toggleOption(optionId: string) {
if (disabled) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
setSubmitted(true);
} finally {
setSubmitting(false);
const next = selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId];
onSelectionChange({ ...selection, selectedIds: next });
}
function updateCustom(value: string) {
if (disabled) {
return;
}
onSelectionChange({ ...selection, custom: value });
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
<span>需要你的确认</span>
{multiPending && pendingIndex !== undefined && pendingCount ? (
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]">
{pendingIndex + 1} / {pendingCount}
</span>
) : null}
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
@@ -513,49 +665,84 @@ function ClarificationCard({
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
<div className="text-muted-foreground text-xs">
可多选(再次点击取消选择)
</div>
{options.map((option) => {
const optionId = option.id || option.label;
const isSelected = selection.selectedIds.includes(optionId);
return (
<Button
key={optionId}
type="button"
variant={isSelected ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => toggleOption(optionId)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span
className={cn(
"text-xs font-normal leading-5",
isSelected
? "text-primary-foreground/80"
: "text-muted-foreground",
)}
>
{option.description}
</span>
) : null}
</span>
{isSelected ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null}
</span>
</Button>
))}
</Button>
);
})}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
value={selection.custom}
onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder={
options.length > 0
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
</div>
) : null}
{isPending ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
) : null}
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
disabled={!canSubmit || submitting}
onClick={() => void onSubmitAll()}
>
<SendIcon className="h-4 w-4" />
发送回答
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
</Button>
</div>
</div>
) : (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
)
) : null}
</div>
);
@@ -114,7 +114,7 @@ export function AppearanceSettingsPage() {
<div className="text-sm font-medium">回答速度指示器</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
在生成回答时显示一个小胶囊,实时估算当前 token/s
并用绿到红的渐变条直观反映快慢。默认关闭。
并用绿到红的渐变条直观反映快慢。默认开启。
</p>
</div>
<Switch
@@ -22,6 +22,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useI18n } from "@/core/i18n/hooks";
@@ -471,7 +472,7 @@ const CATEGORY_COLORS: Record<string, string> = {
correction: "bg-red-100 text-red-700",
};
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
function MemoryV1View({
memory,
}: {
@@ -486,93 +487,112 @@ function MemoryV1View({
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
return (
<div className="space-y-4">
<div className="space-y-3">
{/* 说明横幅 */}
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div>
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-base font-medium">用户画像</h3>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
) : (
<dl className="space-y-2">
{ctxEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
</div>
))}
</dl>
)}
</div>
<Tabs defaultValue="facts">
<TabsList>
<TabsTrigger value="facts">
事实记录
{memory.facts.length > 0 && (
<span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
{memory.facts.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="profile">用户画像 &amp; 历史</TabsTrigger>
</TabsList>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-base font-medium">历史摘要</h3>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
) : (
<dl className="space-y-2">
{historyEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
{/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3">
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
暂无事实记录,对话后系统会自动提取。
</div>
))}
</dl>
)}
</div>
{/* 事实记录 (facts) */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="space-y-2">
{memory.facts.map((fact) => (
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{fact.category}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
) : (
<div className="space-y-2">
{memory.facts.map((fact) => (
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{fact.category}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
{fact.sourceError && (
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
避免:{fact.sourceError}
</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
{fact.sourceError && (
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
避免:{fact.sourceError}
</p>
)}
))}
</div>
))}
)}
</div>
)}
</div>
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4">
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-base font-medium">用户画像</h3>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
) : (
<dl className="space-y-2">
{ctxEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
</div>
))}
</dl>
)}
</div>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-base font-medium">历史摘要</h3>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
) : (
<dl className="space-y-2">
{historyEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
</div>
))}
</dl>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}
@@ -4,6 +4,8 @@ import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
FileArchive,
GlobeIcon,
@@ -11,6 +13,7 @@ import {
LockIcon,
PinIcon,
PinOffIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
@@ -18,7 +21,7 @@ import {
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
@@ -81,6 +85,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
);
}
const PAGE_SIZE = 10;
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
@@ -90,6 +96,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds });
@@ -140,10 +148,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
// Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke.
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page],
);
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
const handleRescan = async () => {
try {
const result = await reconcileMutation.mutateAsync();
if (result.adopted.length > 0) {
toast.success(
t.settings.skills.rescanSkillsAdopted.replace(
"{count}",
String(result.adopted.length),
),
);
} else {
toast.info(t.settings.skills.rescanSkillsNoop);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3">
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleRescan()}
disabled={
reconcileMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
title={t.settings.skills.rescanSkills}
>
{reconcileMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
{reconcileMutation.isPending
? t.settings.skills.rescanSkillsRunning
: t.settings.skills.rescanSkills}
</Button>
<Button
size="sm"
variant="outline"
@@ -220,7 +276,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
)}
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
@@ -228,6 +284,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
isAdmin={isAdmin}
/>
))}
{!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeftIcon className="size-4" />
{t.settings.skills.paginationPrev}
</Button>
<span className="text-muted-foreground text-xs">
{t.settings.skills.paginationPageOf
.replace("{page}", String(page))
.replace("{total}", String(totalPages))}
</span>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{t.settings.skills.paginationNext}
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
@@ -155,18 +155,10 @@ export function WorkspaceNavMenu() {
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/users")}
onClick={() => navigate("/page/workspace/admin")}
>
<UsersIcon />
用户管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/memory")}
>
<BrainIcon />
记忆管理
用户中心
</DropdownMenuItem>
)}
{isAdmin && (
@@ -179,16 +171,6 @@ export function WorkspaceNavMenu() {
定时任务管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/user-activity")
}
>
<HistoryIcon />
用户活动审计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}