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,
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 },
@@ -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 && (
@@ -463,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record<string, unknown>;
result?: string;
result?: string | Record<string, unknown>;
}
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;
}
}
@@ -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>
);
@@ -1,6 +1,7 @@
"use client";
import {
ChevronDown,
Download,
FileJson,
FileText,
@@ -63,6 +64,15 @@ export function RecentChatList() {
const { t } = useI18n();
const router = useRouter();
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 } =
useParams<{
thread_id: string;
@@ -161,12 +171,24 @@ export function RecentChatList() {
return (
<>
<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">
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
<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>
{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>
<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>
<div className="flex w-full flex-col gap-0.5">
{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>
<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")}
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
@@ -446,7 +446,14 @@ export const enUS: Translations = {
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
installSkill: "Install skill",
rescanSkills: "Rescan skills directory",
rescanSkillsRunning: "Scanning…",
rescanSkillsAdopted: "Adopted {count} new skill(s)",
rescanSkillsNoop: "No new skills found on disk",
goToEvolution: "Skill evolution",
paginationPrev: "Prev",
paginationNext: "Next",
paginationPageOf: "Page {page} of {total}",
emptyTitle: "No agent skill yet",
emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
@@ -381,7 +381,14 @@ export interface Translations {
description: string;
createSkill: string;
installSkill: string;
rescanSkills: string;
rescanSkillsRunning: string;
rescanSkillsAdopted: string;
rescanSkillsNoop: string;
goToEvolution: string;
paginationPrev: string;
paginationNext: string;
paginationPageOf: string;
emptyTitle: string;
emptyDescription: string;
emptyButton: string;
@@ -427,7 +427,14 @@ export const zhCN: Translations = {
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
installSkill: "安装技能",
rescanSkills: "重新扫描技能目录",
rescanSkillsRunning: "扫描中…",
rescanSkillsAdopted: "已收纳 {count} 个新技能",
rescanSkillsNoop: "没有发现需要收纳的新技能",
goToEvolution: "自我进化",
paginationPrev: "上一页",
paginationNext: "下一页",
paginationPageOf: "第 {page} / {total} 页",
emptyTitle: "还没有技能",
emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const layers = collectNestedRecords(record);
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
let title =
pickFromLayers(layers, TITLE_ALIASES) ??
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
if (!title && indexLabel) {
title = `结果 ${indexLabel}`;
}
let content =
pickFromLayers(layers, CONTENT_ALIASES) ??
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
title = pickShortTitleFallback(layers, reserved);
}
if (!title && !content && !url) {
// 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
if (!title && !content && !url && !indexLabel) {
return null;
}
+81 -15
View File
@@ -1,5 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { hasSearchResultPayload } from "@/core/messages/search-results";
interface GenericMessageGroup<T = string> {
type: T;
id: string | undefined;
@@ -141,21 +143,63 @@ export function groupMessages<T>(
}
export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") {
return (
splitInlineReasoningFromAIMessage(message)?.content ??
message.content.trim()
);
const payload = extractToolResultPayload(message);
if (typeof payload === "string") {
return payload;
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => (content.type === "text" ? content.text : ""))
.join("\n")
.trim();
if (payload && typeof payload === "object") {
try {
return JSON.stringify(payload);
} catch {
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;
function splitInlineReasoning(content: string) {
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
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) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {
const content = extractTextFromMessage(message);
if (content) {
return content;
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
continue;
}
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;
}
+8 -8
View File
@@ -5,15 +5,15 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
enabled: true,
},
display: {
streamSpeedMeterEnabled: false,
streamSpeedMeterEnabled: true,
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
},
};
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
},
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
@@ -29,7 +29,7 @@ export interface LocalSettings {
display: {
/**
* Whether to render a small token/s speed meter while a streaming
* AI reply is in flight. Default off; controlled by the user from
* AI reply is in flight. Default on; controlled by the user from
* the appearance settings page.
*/
streamSpeedMeterEnabled: boolean;
+16
View File
@@ -135,6 +135,22 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>;
}
export interface ReconcileCustomSkillsResponse {
adopted: string[];
already_registered: string[];
}
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to reconcile skills directory");
}
return response.json() as Promise<ReconcileCustomSkillsResponse>;
}
export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
+11
View File
@@ -11,6 +11,7 @@ import {
getSkill,
getSkillContent,
installSkillUpload,
reconcileCustomSkills,
setSkillDetail,
setSkillPinned,
setSkillPublished,
@@ -95,6 +96,16 @@ export function useInstallSkillUpload() {
});
}
export function useReconcileCustomSkills() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => reconcileCustomSkills(),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPublished() {
const queryClient = useQueryClient();
return useMutation({
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
hideAdvancedFields?: boolean;
}
interface ModelOption {
@@ -31,7 +33,14 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'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 [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
e.preventDefault();
if (!intent.trim()) return;
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({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount,
keywordCount,
writingMode: strictMode ? 'strict' : 'loose',
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined,
});
}
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
{!hideAdvancedFields && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
</>
)}
{models.length > 0 && (
<div className="space-y-1.5">
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
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' },
draft_ready: { label: '草稿', color: 'text-amber-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' },
};
function formatRelative(isoStr: string): string {
export function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
interface SessionItemProps {
export interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
@@ -33,7 +33,7 @@ interface SessionItemProps {
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 [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
error: '出错了',
};
export function AIWritingPanel() {
export function AIWritingPanel({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
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 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
<AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
/>
)}
{/* 意图解析:流式 → 结果卡片承接。
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{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}
</span>
</ReqRow>
<ReqRow label="目标读者">
<span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
{!hideAdvancedFields && (
<ReqRow label="目标读者">
<span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
)}
<ReqRow label="目标字数">
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
</ReqRow>
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
{!hideAdvancedFields && (
<>
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
</>
)}
{state.request.modelName && (
<ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span>
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
hideTargetAudience?: boolean;
}
/**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
</div>
<div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} />
{!hideTargetAudience && (
<Row label="目标读者" value={intent.targetAudience} />
)}
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && (
<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)
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);
return (
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel />
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div>
</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 { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -36,14 +36,33 @@ export default function AgentChatPage() {
const routeBase = useRouteBase();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
const { agent } = useAgent(agent_id);
// If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI
// before the auto-sent message takes over.
const hasPendingForThisAgent = () => {
if (typeof window === "undefined" || !agent_id) return false;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return false;
const parsed = JSON.parse(raw);
return parsed?.agentId === agent_id && !!parsed?.text;
} catch {
return false;
}
};
const [hasStartedConversation, setHasStartedConversation] = useState(
hasPendingForThisAgent,
);
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
const agentDisplayName =
agent?.name ?? (isAgentLoading ? t.common.loading : "");
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
@@ -52,9 +71,10 @@ export default function AgentChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setHasStartedConversation(hasPendingForThisAgent());
}
}, [isNewThread, threadId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewThread, threadId, agent_id]);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
@@ -100,6 +120,26 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id],
);
// ChatPage stashes the opening message in sessionStorage when the user picks
// an agent on the landing screen, then redirects here. Replay it once so the
// user doesn't have to re-type the question on the agent chat page.
const pendingMessageHandled = useRef(false);
useEffect(() => {
if (pendingMessageHandled.current) return;
if (!agent_id || !isNewThread || hasStartedConversation) return;
let pending: { agentId?: string; text?: string } | null = null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (raw) pending = JSON.parse(raw);
} catch {
pending = null;
}
if (!pending || pending.agentId !== agent_id || !pending.text) return;
pendingMessageHandled.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: pending.text, files: [] });
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
@@ -133,8 +173,11 @@ export default function AgentChatPage() {
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_id}
<span
className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
{agentDisplayName}
</span>
</div>
@@ -180,8 +223,8 @@ export default function AgentChatPage() {
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
@@ -212,7 +255,7 @@ export default function AgentChatPage() {
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
<AgentWelcome agent={agent} agentName={agentDisplayName} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
@@ -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() : "暂无";
}
/**
* Compact "M月D日" badge prefixed to each run title so visually identical
* names (the agent often emits the same title every run) become scannable.
* Returns empty string for an invalid / missing date so the title falls back
* to its bare form instead of rendering "Invalid Date".
*/
function formatScheduledDatePrefix(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return `${date.getMonth() + 1}月${date.getDate()}日`;
}
function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: string | null }): boolean {
const name = file.name.toLowerCase();
return (
@@ -373,6 +386,7 @@ export default function PublicScheduledTaskPage() {
const runTitle = stripTimestampSuffix(
run.result?.title || formatDate(run.scheduled_for),
);
const datePrefix = formatScheduledDatePrefix(run.scheduled_for);
const isDeletingThis =
deleteRunMutation.isPending && deleteRunMutation.variables === run.id;
return (
@@ -398,13 +412,18 @@ export default function PublicScheduledTaskPage() {
<TooltipTrigger asChild>
<div
className="text-sm leading-snug font-medium break-all"
title={runTitle}
title={datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
>
{datePrefix && (
<span className="text-muted-foreground mr-1.5 text-xs font-normal tabular-nums">
{datePrefix}
</span>
)}
{runTitle}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[360px] break-all">
{runTitle}
{datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
</TooltipContent>
</Tooltip>
<div className="text-muted-foreground mt-1 text-xs">
+12 -7
View File
@@ -758,11 +758,11 @@ export default function ScheduledTasksPage() {
</Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogContent className="max-w-2xl overflow-hidden">
<DialogHeader className="min-w-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex min-w-0 flex-col gap-3">
<Input
value={form.name}
onChange={(event) =>
@@ -798,15 +798,20 @@ export default function ScheduledTasksPage() {
}))
}
>
<SelectTrigger>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" />
</SelectTrigger>
<SelectContent>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
{agent.description ? ` — ${agent.description}` : ""}
<span className="block truncate">
{agent.name}
{agent.description ? ` — ${agent.description}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
export default function StrategyRoutes() {
return (
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
+19 -28
View File
@@ -29,6 +29,7 @@ import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
@@ -182,7 +183,7 @@ export default function WorkspaceRoutes({
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage />
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
@@ -206,18 +207,32 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* Unified admin user hub */}
<Route
path="admin/users"
path="admin"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<UsersPage />
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
{/* Legacy redirects */}
<Route
path="admin/users"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/user-activity"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/memory"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/scheduled-tasks"
element={
@@ -230,30 +245,6 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminUserActivityPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/memory"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminMemoryPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={