diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 42dccac5..ff9b4045 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -105,6 +105,12 @@ const menuItems: MenuItem[] = [ icon: List, 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 }, diff --git a/frontend-web/src/components/workspace/admin/admin-memory-page.tsx b/frontend-web/src/components/workspace/admin/admin-memory-page.tsx index bd48cde9..7469cd81 100644 --- a/frontend-web/src/components/workspace/admin/admin-memory-page.tsx +++ b/frontend-web/src/components/workspace/admin/admin-memory-page.tsx @@ -29,7 +29,7 @@ import { } from "@/core/admin/hooks"; import { useAllUsers } from "@/core/auth/hooks"; -function getEntries(data: Record): string[] { +export function getEntries(data: Record): 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(null); diff --git a/frontend-web/src/components/workspace/admin/admin-user-hub.tsx b/frontend-web/src/components/workspace/admin/admin-user-hub.tsx new file mode 100644 index 00000000..44b851f6 --- /dev/null +++ b/frontend-web/src/components/workspace/admin/admin-user-hub.tsx @@ -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).text; + if (typeof text === "string") return text; + } + return ""; + }) + .join(""); + } + if (typeof content === "object") { + const inner = (content as Record).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 ( +
+
+
邮箱
+
{user.email}
+
角色
+
+ {user.system_role === "admin" ? ( + 管理员 + ) : ( + 普通用户 + )} +
+
Skills
+
{user.skill_count}
+
Agents
+
{user.agent_count}
+
User ID
+
{user.id}
+
+
+ ); +} + +// ── 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 ( +
+ + {role === "user" ? "用户提问" : "AI 回答"} + +
+ {text} +
+
+ ); +} + +// ── UserAuditPanel ────────────────────────────────────────────────────────── + +function UserAuditPanel({ userId }: { userId: string }) { + const [threadId, setThreadId] = useState(null); + + const { data: threads, isLoading: threadsLoading } = useUserThreads(userId); + const { data: messages, isLoading: messagesLoading } = useUserThreadMessages( + userId, + threadId, + ); + + return ( +
+ {/* Thread list */} +
+
+ 会话列表{threads ? `(${threads.length})` : ""} +
+ + {threadsLoading && ( +
+ 加载中 +
+ )} + {!threadsLoading && (threads?.length ?? 0) === 0 && ( +
+ 该用户暂无会话 +
+ )} +
+ {(threads ?? []).map((thread) => ( + + ))} +
+
+
+ + {/* Messages */} + +
+ {!threadId && ( +
+ + 请从左侧选择一个会话 +
+ )} + {threadId && messagesLoading && ( +
+ 加载中 +
+ )} + {threadId && !messagesLoading && ( +
+ {(messages ?? []).map((m, i) => ( + + ))} + {(messages ?? []).every((m) => !roleOf(m)) && ( +
+ 该会话暂无问答记录 +
+ )} +
+ )} +
+
+
+ ); +} + +// ── 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("default"); + + if (isLoading) { + return ( +
+ 加载中… +
+ ); + } + + 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 ( +
+ +
+ {/* 智能体下拉选择器 */} +
+ 智能体: + + {!isV1 && currentBucket && ( + + {getEntries(currentBucket.data).length} 条 + + )} +
+ + {/* 当前智能体记忆内容 */} + {!currentBucket ? ( +
+ 暂无记忆数据 +
+ ) : isV1 ? ( + + ) : ( + + )} +
+
+
+ ); +} + +// ── AdminUserHub (main) ───────────────────────────────────────────────────── + +export function AdminUserHub() { + const { data: users, isLoading: usersLoading } = useAllUsers(true); + const [selectedUserId, setSelectedUserId] = useState(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 ( +
+
+

Admin 用户中心

+

+ 管理用户信息、审计历史问答、编辑用户记忆。 +

+
+ +
+ {/* Left: user list */} +
+
+ + setUserQuery(e.target.value)} + placeholder="搜索用户邮箱" + className="pl-8" + /> +
+ + {usersLoading && ( +
+ 加载中 +
+ )} + {!usersLoading && filteredUsers.length === 0 && ( +
+ 无用户 +
+ )} +
+ {filteredUsers.map((u) => ( + + ))} +
+
+
+ + {/* Right: tabs */} + {!selectedUserId ? ( +
+ 从左侧选择一个用户 +
+ ) : ( + setActiveTab(v as typeof activeTab)} + className="flex min-w-0 flex-1 flex-col overflow-hidden" + > +
+ + 用户信息 + 活动审计 + 记忆管理 + +
+ + + {selectedUser && } + + + + + + + + + +
+ )} +
+
+ ); +} diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index af123c30..069b901d 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
-
+
-
- +
+ {agent.name} {agent.model && ( diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 63ff8e22..40f5348d 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -463,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> { interface CoTToolCallStep extends GenericCoTStep<"toolCall"> { name: string; args: Record; - result?: string; + result?: string | Record; } type CoTStep = CoTReasoningStep | CoTToolCallStep; @@ -495,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] { }; const toolCallId = tool_call.id; if (toolCallId) { - const toolCallResult = findToolCallResult(toolCallId, messages); - if (toolCallResult) { - try { - const json = JSON.parse(toolCallResult); - step.result = json; - } catch { + const toolCallResult = findToolCallResult( + toolCallId, + messages, + tool_call.name, + ); + 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; } } diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 6c6a745f..63cee2fb 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -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 + >(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 ; } @@ -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 (
+ handleClarificationSelectionChange(messageId, next) + } + submitting={submittingClarification} + canSubmit={hasAnyClarificationAnswer} + onSubmitAll={handleSubmitAllClarifications} /> void | Promise; rehypePlugins: ReturnType; + isPending: boolean; + pendingIndex?: number; + pendingCount?: number; + isLastPending: boolean; + selection: ClarificationSelection; + onSelectionChange: (next: ClarificationSelection) => void; + submitting: boolean; + canSubmit: boolean; + onSubmitAll: () => void | Promise; }) { 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 (
-
- 需要你的确认 +
+ 需要你的确认 + {multiPending && pendingIndex !== undefined && pendingCount ? ( + + {pendingIndex + 1} / {pendingCount} + + ) : null}
{payload.context ? (

@@ -513,49 +665,84 @@ function ClarificationCard({ {options.length > 0 ? (

- {options.map((option) => ( - - ))} + + ); + })}
) : null} {payload.allow_custom !== false ? (