This commit is contained in:
董宏凯 committed 2026-05-15 23:27:48 +08:00
commit e08e7be24c
404 files changed
+113007 -921

No files matched your search

@@ -0,0 +1,293 @@
"use client";
import { Loader2, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { TagChip } from "@/components/workspace/tags/tag-chip";
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
import { useI18n } from "@/core/i18n/hooks";
import {
useCreateTag,
useDeleteTag,
useTags,
useUpdateTag,
type Tag,
type TagType,
} from "@/core/tags";
import { cn } from "@/lib/utils";
function TagFormDialog({
open,
onOpenChange,
tagType,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
tagType: TagType;
editing: Tag | null;
}) {
const { t } = useI18n();
const createTag = useCreateTag();
const updateTag = useUpdateTag();
const [name, setName] = useState("");
const [color, setColor] = useState("gray");
const [description, setDescription] = useState("");
// Sync form state whenever the dialog (re)opens.
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${editing?.id ?? "new"}`;
if (open && syncedFor !== syncKey) {
setName(editing?.name ?? "");
setColor(editing?.color ?? "gray");
setDescription(editing?.description ?? "");
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const pending = createTag.isPending || updateTag.isPending;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
if (editing) {
await updateTag.mutateAsync({
id: editing.id,
input: { name: trimmed, color, description },
});
} else {
await createTag.mutateAsync({
name: trimmed,
tag_type: tagType,
color,
description,
});
}
onOpenChange(false);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.name}</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.adminTags.namePlaceholder}
maxLength={256}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.color}</label>
<div className="flex flex-wrap gap-2">
{TAG_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setColor(c.name)}
aria-label={c.name}
className={cn(
"size-6 rounded-full border-2",
c.swatch,
color === c.name
? "border-foreground"
: "border-transparent",
)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{t.adminTags.tagDescription}
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.adminTags.descriptionPlaceholder}
maxLength={1024}
rows={2}
/>
</div>
{name.trim() && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Preview:</span>
<TagChip tag={{ name: name.trim(), color }} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.adminTags.cancel}
</Button>
<Button onClick={handleSave} disabled={pending || !name.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}
{t.adminTags.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function TagManagementPage() {
const { t } = useI18n();
const [activeType, setActiveType] = useState<TagType>("agent");
const { data: tags, isLoading, error } = useTags(activeType);
const deleteTag = useDeleteTag();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tag | null>(null);
const openCreate = () => {
setEditing(null);
setDialogOpen(true);
};
const openEdit = (tag: Tag) => {
setEditing(tag);
setDialogOpen(true);
};
const handleDelete = async (tag: Tag) => {
if (!window.confirm(t.adminTags.deleteConfirm)) return;
try {
await deleteTag.mutateAsync(tag.id);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminTags.title}
description={t.adminTags.description}
>
<div className="flex items-center justify-between gap-4">
<Tabs
value={activeType}
onValueChange={(v) => setActiveType(v as TagType)}
>
<TabsList variant="line">
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
<TabsTrigger value="scheduled_task">
{t.adminTags.typeScheduledTask}
</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />
{t.adminTags.createButton}
</Button>
</div>
<div className="mt-4">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive py-4 text-sm">{String(error)}</div>
)}
{!isLoading && !error && tags && tags.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
{t.adminTags.empty}
</div>
)}
{!isLoading && !error && tags && tags.length > 0 && (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.name}
</th>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.tagDescription}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.usageCount}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.actions}
</th>
</tr>
</thead>
<tbody>
{tags.map((tag, i) => (
<tr
key={tag.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">
<TagChip tag={tag} />
</td>
<td className="text-muted-foreground px-4 py-3">
{tag.description || "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{tag.usage_count}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(tag)}
aria-label={t.adminTags.edit}
>
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tag)}
aria-label={t.adminTags.delete}
>
<Trash2Icon className="text-destructive size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</SettingsSection>
<TagFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
tagType={activeType}
editing={editing}
/>
</div>
);
}
@@ -6,6 +6,7 @@ import {
EyeOffIcon,
MessageSquareIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -33,6 +34,8 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
@@ -212,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
@@ -242,8 +252,19 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{(canEdit || canDelete || isOwned) && (
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
{isOwned && (
<Button
size="icon"
@@ -414,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
)}
</>
);
}
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -14,9 +15,12 @@ import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -83,10 +87,19 @@ export function AgentGallery() {
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList className="mx-6 mt-4 w-auto self-start">
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="mine">自己的</TabsTrigger>
</TabsList>
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
</TabsList>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="w-[420px] max-w-[50%]"
/>
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
@@ -2,17 +2,16 @@
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
PencilIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -34,16 +33,16 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import { useArtifactContent } from "@/core/artifacts/hooks";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
@@ -118,33 +117,19 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const queryClient = useQueryClient();
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(null);
const [editedContent, setEditedContent] = useState<string | null>(null);
// Tracks last manually-saved content for write-file: artifacts (whose displayContent
// always reflects the original tool-call output, not the on-disk file after save).
const [savedContent, setSavedContent] = useState<string | null>(null);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? content ?? "";
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const canEdit = isCodeFile && !isSkillFile && !thread.isLoading;
const activeContent = editedContent ?? displayContent;
const isDirty = editedContent !== null && editedContent !== displayContent;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (language === "html") return "HTML";
@@ -154,12 +139,34 @@ export function ArtifactFileDetail({
return "FILE";
}, [isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setEditedContent(null);
setSavedContent(null);
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
@@ -322,41 +329,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleSave = useCallback(async () => {
if (!isDirty || isSaving) return;
setIsSaving(true);
try {
const pathWithoutLeadingSlash = filepath.replace(/^\//, "");
const res = await apiFetch(
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: activeContent }),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail ?? "保存失败");
}
if (isWriteFile) {
setSavedContent(activeContent);
} else {
await queryClient.invalidateQueries({
queryKey: ["artifact", filepathFromProps, threadId],
});
}
setEditedContent(null);
toast.success("已保存");
} catch (error) {
console.error("Save artifact failed:", error);
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
}, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]);
const handleInstallSkill = useCallback(async () => {
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
@@ -376,7 +349,33 @@ export function ArtifactFileDetail({
} finally {
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
}
}, [
canEditArtifact,
draftContent,
filepath,
isMock,
threadId,
updateArtifact,
]);
return (
<Artifact className={cn(className)}>
@@ -453,30 +452,19 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
{canEdit && viewMode === "code" && !isDirty && (
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
<PencilIcon className="size-3" />
可编辑
</span>
)}
{canEdit && viewMode === "code" && isDirty && (
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
未保存
</span>
)}
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEdit && viewMode === "code" && (
<ArtifactAction
icon={isSaving ? LoaderIcon : SaveIcon}
label="保存"
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
disabled={isSaving || !isDirty}
onClick={handleSave}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
label="保存 Markdown"
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
disabled={!hasLocalEdits || updateArtifact.isPending}
onClick={handleSaveArtifact}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
@@ -562,13 +550,19 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{showResumeAutoScroll && (
<button
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{isApplyingEdit && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
正在改写 Markdown
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
onClick={handleResumeAutoScroll}
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
@@ -580,30 +574,31 @@ export function ArtifactFileDetail({
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={activeContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
/>
)}
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
{isCodeFile && viewMode === "code" && (
<div
className="size-full"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
}}
>
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={activeContent}
readonly={!canEdit}
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
/>
</div>
)}
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
{!isCodeFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
@@ -618,15 +613,17 @@ export function ArtifactFileDetail({
);
}
export function ArtifactFilePreview({
content,
language,
animated = false,
}: {
content: string;
language: string;
animated?: boolean;
}) {
export function ArtifactFilePreview({
content,
language,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
useEffect(() => {
@@ -657,12 +654,15 @@ export function ArtifactFilePreview({
className="size-full"
{...streamdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}
</Streamdown>
</div>
);
}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
if (language === "html") {
return (
@@ -38,20 +38,22 @@ export function CodeEditor({
className,
placeholder,
value,
onChange,
readonly,
disabled,
autoFocus,
onChange,
settings,
streamingReadonlyFallback = true,
}: {
className?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
onChange?: (value: string) => void;
settings?: unknown;
streamingReadonlyFallback?: boolean;
}) {
const {
thread: { isLoading },
@@ -79,7 +81,7 @@ export function CodeEditor({
className,
)}
>
{isLoading ? (
{isLoading && streamingReadonlyFallback ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
@@ -3,6 +3,7 @@
import type { ChatStatus } from "ai";
import {
CheckIcon,
FileTextIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -117,6 +118,7 @@ export function InputBox({
onFollowupsVisibilityChange,
onSubmit,
onStop,
promptPrefix,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -128,6 +130,7 @@ export function InputBox({
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -140,11 +143,22 @@ export function InputBox({
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
* Optional toggle rendered next to the model selector. Used for the
* admin-configurable prompt-prefix switch on the chat input. Omitting the
* prop hides the button entirely.
*/
promptPrefix?: {
label: string;
on: boolean;
onToggle: () => void;
};
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
@@ -252,6 +266,13 @@ export function InputBox({
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
@@ -530,6 +551,26 @@ export function InputBox({
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
<Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none",
context.writing_mode
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
)}
onClick={handleWritingModeToggle}
>
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -819,6 +860,34 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
{promptPrefix && (
<Tooltip
content={
promptPrefix.on
? `已开启:${promptPrefix.label}`
: `点击开启:${promptPrefix.label}`
}
>
<PromptInputButton
type="button"
onClick={promptPrefix.onToggle}
aria-pressed={promptPrefix.on}
className={cn(
"rounded-full border px-3 shadow-none transition-colors",
promptPrefix.on
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#8a7558] hover:bg-white",
)}
>
<span className="max-w-[160px] truncate text-xs font-normal">
{promptPrefix.label}
</span>
{promptPrefix.on && (
<CheckIcon className="ml-1 size-3.5 text-[#7a5417]" />
)}
</PromptInputButton>
</Tooltip>
)}
<ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
@@ -329,41 +329,43 @@ function ToolCall({
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
setTimeout(() => {
const url = new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString();
if (selectedArtifact === url) {
return;
}
select(url, true);
setOpen(true);
}, 100);
}
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || selectedArtifact === artifactPath) {
return;
}
select(artifactPath, true);
setOpen(true);
}, 100);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath) {
return;
}
select(artifactPath);
setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
@@ -31,7 +31,10 @@ import { StreamingIndicator } from "../streaming-indicator";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { MessageTokenUsageList } from "./message-token-usage";
import {
MessageMetricsProvider,
MessageTokenUsageList,
} from "./message-token-usage";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
@@ -69,6 +72,7 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
@@ -265,6 +269,7 @@ export function MessageList({
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
</Conversation>
);
@@ -353,9 +358,9 @@ function SetupAgentApprovalCard({
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">
创建专家审批
创建智能体审批
</div>
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
</div>
{approvalPayload.name ? (
@@ -1,28 +1,98 @@
import type { Message } from "@langchain/langgraph-sdk";
import { CoinsIcon } from "lucide-react";
import { CoinsIcon, GaugeIcon } from "lucide-react";
import { useI18n } from "@/core/i18n/hooks";
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
MessageMetricsContext,
useMessageMetric,
useMessageMetrics,
type MessageMetric,
} from "./use-message-metrics";
const FULL_SCALE_TOKENS_PER_SEC = 80;
function classifySpeed(tps: number): { color: string; label: string } {
if (tps >= 50) return { color: "#16a34a", label: "极快" };
if (tps >= 30) return { color: "#65a30d", label: "流畅" };
if (tps >= 15) return { color: "#eab308", label: "一般" };
if (tps >= 5) return { color: "#f97316", label: "偏慢" };
return { color: "#ef4444", label: "缓慢" };
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
metric.tokensPerSec >= 100
? Math.round(metric.tokensPerSec).toString()
: metric.tokensPerSec.toFixed(1);
const prefix = metric.fromUsage ? "" : "~";
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
{prefix}
{display}
</span>
<span className="text-muted-foreground">tok/s</span>
<span className="relative inline-block h-1 w-12 overflow-hidden rounded-full bg-current/15 align-middle">
<span
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-200 ease-out"
style={{ width: `${fill * 100}%`, backgroundColor: color }}
/>
</span>
</span>
);
}
export function MessageTokenUsage({
className,
enabled = false,
isLoading = false,
message,
metric,
}: {
className?: string;
enabled?: boolean;
isLoading?: boolean;
message: Message;
metric?: MessageMetric;
}) {
const { t } = useI18n();
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
const contextMetric = useMessageMetric(message.id as string | undefined);
const resolvedMetric = metric ?? contextMetric;
if (!enabled || isLoading || message.type !== "ai") {
if (message.type !== "ai") {
return null;
}
// Show speed-only line while streaming (no usage yet) if the user opted in,
// otherwise wait until usage arrives so the bar doesn't render on its own.
const usage = getUsageMetadata(message);
const showUsage = enabled && !isLoading && Boolean(usage);
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
if (!showUsage && !showSpeed) {
return null;
}
return (
<div
@@ -31,12 +101,12 @@ export function MessageTokenUsage({
className,
)}
>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
{usage ? (
{showUsage && usage ? (
<>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
<span>
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
</span>
@@ -47,9 +117,17 @@ export function MessageTokenUsage({
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
</span>
</>
) : (
<span>{t.tokenUsage.unavailableShort}</span>
)}
) : null}
{showSpeed && resolvedMetric ? (
<>
<SpeedPill metric={resolvedMetric} />
{resolvedMetric.durationMs > 0 && (
<span className="font-mono tabular-nums">
{formatDuration(resolvedMetric.durationMs)}
</span>
)}
</>
) : null}
</div>
);
}
@@ -65,12 +143,14 @@ export function MessageTokenUsageList({
isLoading?: boolean;
messages: Message[];
}) {
if (!enabled || isLoading) {
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
if (!enabled && !speedEnabled) {
return null;
}
const aiMessages = messages.filter((message) => message.type === "ai");
if (aiMessages.length === 0) {
return null;
}
@@ -89,3 +169,25 @@ export function MessageTokenUsageList({
</>
);
}
/**
* Hoist the metrics map computation to the top of the message list so
* descendant {@link MessageTokenUsage} components can read it via context
* instead of each running its own hook.
*/
export function MessageMetricsProvider({
messages,
isLoading,
children,
}: {
messages: Message[];
isLoading: boolean;
children: React.ReactNode;
}) {
const metrics = useMessageMetrics(messages, isLoading);
return (
<MessageMetricsContext.Provider value={metrics}>
{children}
</MessageMetricsContext.Provider>
);
}
@@ -0,0 +1,235 @@
"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
*/
durationMs: number;
/** True while this is the currently streaming message. */
isActive: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
* only to add a "~" prefix when the value is approximate.
*/
fromUsage: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
return message.type === "ai";
}
function getMessageId(message: Message, fallbackIndex: number): string {
const id = (message.id as string | undefined) ?? undefined;
return id ?? `idx-${fallbackIndex}`;
}
function messageText(message: Message): string {
// Count both the visible answer text *and* the reasoning trace so the speed
// indicator stays meaningful during the thinking phase — for reasoning
// models the answer body can be empty for tens of seconds while the model
// streams reasoning tokens, and ignoring those would make the meter sit at
// zero. We treat the two as one continuous stream of generated characters.
const body = textOfMessage(message);
const reasoning = extractReasoningContentFromMessage(message) ?? "";
const bodyText = typeof body === "string" ? body : "";
return bodyText + reasoning;
}
/**
* Returns a Map keyed by AI message id → live metric snapshot.
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
*/
export function useMessageMetrics(
messages: Message[],
isLoading: boolean,
): Map<string, MessageMetric> {
const timingRef = useRef<Map<string, TimingEntry>>(new Map());
const lastSampleAtRef = useRef(0);
// Bump a counter to force recomputation when samples push but message refs
// didn't change — needed because the timing map mutation alone wouldn't
// re-render anything.
const [, setTick] = useState(0);
const lastActiveIdRef = useRef<string | null>(null);
// Walk messages and update timings. We intentionally read/write the ref
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const text = messageText(message);
if (!text) continue;
let entry = timingRef.current.get(id);
if (!entry) {
entry = {
firstSeenAt: now,
endedAt: null,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
}
}
lastActiveIdRef.current = activeId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
return () => window.clearInterval(interval);
}, [isLoading]);
// Build the snapshot map. We compute on every render so consumers see the
// freshest duration; the underlying timing storage is the source of truth.
const out = new Map<string, MessageMetric>();
const now = performance.now();
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
}
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
fromUsage,
});
}
return out;
}
const EMPTY_METRICS: Map<string, MessageMetric> = new Map();
/**
* Context for sharing the metrics map computed by ``useMessageMetrics`` from
* MessageList down to per-message renderers like MessageListItem, without
* threading a prop through every grouping branch.
*/
export const MessageMetricsContext = createContext<Map<string, MessageMetric>>(
EMPTY_METRICS,
);
export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined {
const metrics = useContext(MessageMetricsContext);
return messageId ? metrics.get(messageId) : undefined;
}
@@ -1,212 +0,0 @@
"use client";
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
SidebarMenuButton,
useSidebar,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
export function NotificationBell() {
const { t } = useI18n();
const { open: isSidebarOpen } = useSidebar();
const [open, setOpen] = useState(false);
// Render badge from the lightweight count endpoint so the bell stays cheap
// when nobody opens it. Only when the user actually opens the dropdown do
// we pay for the full list query.
const { data: unreadCount = 0 } = useUnreadCount();
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label={t.notifications.bellAria}
>
{isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<div className="relative">
<BellIcon className="size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
<span>{t.notifications.title}</span>
{hasUnread && (
<span className="text-muted-foreground ml-auto text-xs">
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
</span>
)}
</div>
) : (
<div className="flex size-full items-center justify-center">
<div className="relative">
<BellIcon className="text-sidebar-foreground/80 size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
)}
</div>
</div>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
sideOffset={8}
className="w-96 p-0"
>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<span className="text-sm font-semibold">
{t.notifications.title}
</span>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
</div>
<ScrollArea className="max-h-[60vh]">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.notifications.empty}
</div>
) : (
<ul className="divide-y">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
!notification.read && "bg-blue-500/5",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-2">
{!notification.read && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"truncate text-sm",
!notification.read && "font-medium",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground line-clamp-2 text-xs">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,329 @@
"use client";
import {
BellIcon,
CheckCheckIcon,
ChevronLeftIcon,
Loader2,
SettingsIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { useLocalSettings } from "@/core/settings";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
type NotificationView = "list" | "settings";
export function NotificationDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const [view, setView] = useState<NotificationView>("list");
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const { data: unreadCount = 0 } = useUnreadCount();
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
function handleOpenChange(next: boolean) {
onOpenChange(next);
// 关闭弹窗时回到列表视图,下次打开默认看到通知列表。
if (!next) setView("list");
}
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="flex max-h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
aria-describedby={undefined}
>
{view === "list" ? (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<DialogTitle className="text-base font-semibold">
{t.notifications.title}
</DialogTitle>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.settingsAria}
onClick={() => setView("settings")}
>
<SettingsIcon className="size-4" />
</Button>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="w-full px-3 py-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-20 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<div className="bg-muted flex size-14 items-center justify-center rounded-full">
<BellIcon className="text-muted-foreground size-6" />
</div>
<p className="text-muted-foreground text-sm">
{t.notifications.empty}
</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer rounded-lg border px-4 py-3 transition-colors hover:bg-accent/50",
!notification.read
? "border-blue-500/30 bg-blue-500/5"
: "border-border",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
notification.read ? "bg-transparent" : "bg-blue-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<p
className={cn(
"truncate text-sm",
notification.read
? "font-medium"
: "font-semibold",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-xs">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</ScrollArea>
</>
) : (
<NotificationSettingsView onBack={() => setView("list")} />
)}
</DialogContent>
</Dialog>
);
}
function NotificationSettingsView({ onBack }: { onBack: () => void }) {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = (enabled: boolean) => {
setSettings("notification", { enabled });
};
return (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.backAria}
onClick={onBack}
>
<ChevronLeftIcon className="size-4" />
</Button>
<DialogTitle className="text-base font-semibold">
{t.notifications.settingsTitle}
</DialogTitle>
</div>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-3 py-6">
{!isSupported ? (
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
) : (
<>
<div className="flex items-start justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t.settings.notification.description}
</p>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
)}
</>
)}
</div>
</>
);
}
@@ -0,0 +1,126 @@
"use client";
import { useMemo } from "react";
import { CopyIcon, LogOutIcon, UserIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { clearStoredAuth, getStoredAuth } from "@/core/auth";
import { SettingsSection } from "./settings-section";
function roleLabel(role?: string) {
if (role === "admin") return "管理员";
if (role === "user") return "普通用户";
return role || "未知角色";
}
function copy(text: string, hint = "已复制") {
if (!text) return;
navigator.clipboard
.writeText(text)
.then(() => toast.success(hint))
.catch(() => toast.error("复制失败"));
}
export function AccountSettingsPage() {
const auth = useMemo(() => getStoredAuth(), []);
if (!auth) {
return (
<SettingsSection title="个人信息" description="当前未登录或登录信息无法读取。">
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
没有可显示的账号信息。
</div>
</SettingsSection>
);
}
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
const displayName = usernameFromEmail || auth.user_id;
const handleLogout = () => {
clearStoredAuth();
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
});
};
return (
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full">
<UserIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="text-muted-foreground truncate text-xs">
{roleLabel(auth.system_role)}
</div>
</div>
<Badge variant={auth.system_role === "admin" ? "default" : "secondary"}>
{roleLabel(auth.system_role)}
</Badge>
</div>
<InfoRow
label="用户名"
value={displayName}
onCopy={() => copy(displayName, "用户名已复制")}
/>
<InfoRow
label="用户 ID"
value={auth.user_id}
onCopy={() => copy(auth.user_id, "用户 ID 已复制")}
/>
<InfoRow label="账号角色" value={roleLabel(auth.system_role)} />
<InfoRow
label="账号状态"
value={auth.needs_setup ? "待初始化" : "已就绪"}
/>
<div className="flex justify-end pt-2">
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOutIcon className="size-4" />
退出登录
</Button>
</div>
</div>
</SettingsSection>
);
}
function InfoRow({
label,
value,
onCopy,
}: {
label: string;
value: string;
onCopy?: () => void;
}) {
return (
<div className="flex items-center gap-3 border-t pt-3 first:border-t-0 first:pt-0">
<div className="text-muted-foreground w-20 shrink-0 text-xs">{label}</div>
<div className="min-w-0 flex-1 truncate text-sm">{value || "—"}</div>
{onCopy && value && (
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={onCopy}
aria-label={`复制${label}`}
>
<CopyIcon className="size-3.5" />
</Button>
)}
</div>
);
}
@@ -12,8 +12,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -27,6 +29,7 @@ export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme, systemTheme } = useTheme();
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo(
() => [
@@ -107,6 +110,30 @@ export function AppearanceSettingsPage() {
</SelectContent>
</Select>
</SettingsSection>
<Separator />
<SettingsSection
title="对话体验"
description="问答过程中的辅助显示。"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<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
checked={settings.display.streamSpeedMeterEnabled}
onCheckedChange={(checked) =>
setSettings("display", { streamSpeedMeterEnabled: checked })
}
aria-label="回答速度指示器"
/>
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,362 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useUpsertMCPServer } from "@/core/mcp/hooks";
import type {
MCPServerConfig,
MCPServerTransport,
} from "@/core/mcp/types";
type EditMode = "create" | "edit";
export interface MCPServerEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: EditMode;
originalName?: string;
initialServer?: MCPServerConfig;
existingNames: string[];
}
const TRANSPORT_OPTIONS: { value: MCPServerTransport; label: string }[] = [
{ value: "stdio", label: "stdio(本地进程)" },
{ value: "http", label: "http(远端 HTTP)" },
{ value: "sse", label: "sse(远端 SSE)" },
];
const DEFAULT_SERVER: MCPServerConfig = {
enabled: true,
type: "stdio",
command: "",
args: [],
env: {},
url: "",
headers: {},
description: "",
disabled_tools: [],
};
function argsToText(args?: string[]): string {
return (args ?? []).join("\n");
}
function textToArgs(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function kvToText(map?: Record<string, string>): string {
if (!map) return "";
return Object.entries(map)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function textToKV(text: string): { value: Record<string, string>; error?: string } {
const result: Record<string, string> = {};
const lines = text.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
const eq = line.indexOf("=");
if (eq <= 0) {
return { value: {}, error: `每行需为 KEY=VALUE 格式:${line}` };
}
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1);
if (!key) {
return { value: {}, error: `键名不能为空:${line}` };
}
result[key] = value;
}
return { value: result };
}
export function MCPServerEditDialog({
open,
onOpenChange,
mode,
originalName,
initialServer,
existingNames,
}: MCPServerEditDialogProps) {
const upsert = useUpsertMCPServer();
const [name, setName] = useState("");
const [type, setType] = useState<MCPServerTransport>("stdio");
const [enabled, setEnabled] = useState(true);
const [description, setDescription] = useState("");
const [command, setCommand] = useState("");
const [argsText, setArgsText] = useState("");
const [envText, setEnvText] = useState("");
const [url, setUrl] = useState("");
const [headersText, setHeadersText] = useState("");
const [disabledToolsText, setDisabledToolsText] = useState("");
useEffect(() => {
if (!open) return;
const base = initialServer ?? DEFAULT_SERVER;
setName(originalName ?? "");
setType((base.type ?? "stdio") as MCPServerTransport);
setEnabled(base.enabled);
setDescription(base.description ?? "");
setCommand(base.command ?? "");
setArgsText(argsToText(base.args));
setEnvText(kvToText(base.env));
setUrl(base.url ?? "");
setHeadersText(kvToText(base.headers));
setDisabledToolsText((base.disabled_tools ?? []).join("\n"));
}, [open, initialServer, originalName]);
const nameConflict = useMemo(() => {
const trimmed = name.trim();
if (!trimmed) return false;
if (mode === "edit" && trimmed === originalName) return false;
return existingNames.includes(trimmed);
}, [name, existingNames, mode, originalName]);
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("请填写 MCP 服务器名称");
return;
}
if (nameConflict) {
toast.error(`已存在同名 MCP 服务器:${trimmedName}`);
return;
}
const env = textToKV(envText);
if (env.error) {
toast.error(`环境变量格式错误:${env.error}`);
return;
}
const headers = textToKV(headersText);
if (headers.error) {
toast.error(`Headers 格式错误:${headers.error}`);
return;
}
const args = textToArgs(argsText);
const disabledTools = textToArgs(disabledToolsText);
if (type === "stdio" && !command.trim()) {
toast.error("stdio 传输需要填写启动命令");
return;
}
if ((type === "http" || type === "sse") && !url.trim()) {
toast.error(`${type} 传输需要填写服务器 URL`);
return;
}
const server: MCPServerConfig = {
enabled,
type,
description: description.trim(),
command: type === "stdio" ? command.trim() : null,
args: type === "stdio" ? args : [],
env: type === "stdio" ? env.value : {},
url: type === "stdio" ? null : url.trim(),
headers: type === "stdio" ? {} : headers.value,
disabled_tools: disabledTools,
};
try {
await upsert.mutateAsync({
serverName: trimmedName,
server,
originalName: mode === "edit" ? originalName : undefined,
});
toast.success(mode === "create" ? "MCP 服务器已创建" : "MCP 服务器已更新");
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>
{mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<Field label="名称" hint="作为 MCP 服务器的唯一标识,用于工具命名前缀">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 github、filesystem"
aria-invalid={nameConflict || undefined}
/>
{nameConflict && (
<div className="text-destructive text-xs">已存在同名服务器</div>
)}
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="传输类型">
<Select
value={type}
onValueChange={(v) => setType(v as MCPServerTransport)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="启用">
<div className="flex h-9 items-center">
<Switch
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-muted-foreground ml-2 text-xs">
{enabled ? "已启用" : "已停用"}
</span>
</div>
</Field>
</div>
<Field label="描述">
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="给这个 MCP 服务器写一段说明,会展示在工具列表里"
/>
</Field>
{type === "stdio" && (
<>
<Field
label="启动命令"
hint="例如 npx、uvx、python,配合参数启动 MCP server 进程"
>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="npx"
/>
</Field>
<Field label="参数" hint="每行一个参数">
<Textarea
rows={3}
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
placeholder={"-y\n@modelcontextprotocol/server-github"}
/>
</Field>
<Field
label="环境变量"
hint="每行 KEY=VALUE;值以 $ 开头会在加载时解析为对应环境变量"
>
<Textarea
rows={3}
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder={"GITHUB_TOKEN=$GITHUB_TOKEN"}
/>
</Field>
</>
)}
{(type === "http" || type === "sse") && (
<>
<Field label="服务器 URL">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/mcp"
/>
</Field>
<Field label="Headers" hint="每行 KEY=VALUE">
<Textarea
rows={3}
value={headersText}
onChange={(e) => setHeadersText(e.target.value)}
placeholder={"Authorization=Bearer xxx"}
/>
</Field>
</>
)}
<Field
label="禁用的工具"
hint="每行一个工具名(不带服务器前缀),加载时会被过滤掉"
>
<Textarea
rows={2}
value={disabledToolsText}
onChange={(e) => setDisabledToolsText(e.target.value)}
placeholder={"some_dangerous_tool"}
/>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={upsert.isPending}>
{upsert.isPending
? "保存中..."
: mode === "create"
? "创建"
: "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<div className="text-sm font-medium">{label}</div>
{hint && <div className="text-muted-foreground text-xs">{hint}</div>}
{children}
</div>
);
}
@@ -1,92 +0,0 @@
"use client";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { SettingsSection } from "./settings-section";
export function NotificationSettingsPage() {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = async (enabled: boolean) => {
setSettings("notification", {
enabled,
});
};
if (!isSupported) {
return (
<SettingsSection
title={t.settings.notification.title}
description={t.settings.notification.description}
>
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
</SettingsSection>
);
}
return (
<SettingsSection
title={t.settings.notification.title}
description={
<div className="flex items-center gap-2">
<div>{t.settings.notification.description}</div>
<div>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
</div>
}
>
<div className="flex flex-col gap-4">
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<div className="flex flex-col gap-4">
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
</div>
)}
</div>
</SettingsSection>
);
}
@@ -1,12 +1,6 @@
"use client";
import {
BellIcon,
BrainIcon,
PaletteIcon,
SparklesIcon,
WrenchIcon,
} from "lucide-react";
import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -17,20 +11,18 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
| "skills"
| "notification"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
@@ -58,26 +50,23 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.appearance,
icon: PaletteIcon,
},
{
id: "notification",
label: t.settings.sections.notification,
icon: BellIcon,
},
{
id: "memory",
label: t.settings.sections.memory,
icon: BrainIcon,
},
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
{
id: "account",
label: "个人信息",
icon: UserIcon,
},
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
],
[
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.skills,
t.settings.sections.notification,
t.settings.sections.about,
],
);
@@ -123,11 +112,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
</nav>
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "skills" && <SkillSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -12,6 +12,7 @@ import {
PinIcon,
PinOffIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
@@ -46,6 +47,9 @@ import {
ItemDescription,
} from "@/components/ui/item";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
@@ -54,6 +58,7 @@ import {
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkillContent,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -66,44 +71,35 @@ import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
const { skills, isLoading, error } = useSkills();
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
) : (
<SkillSettingsList skills={skills} onClose={onClose} />
)}
<SkillSettingsList onClose={onClose} />
</SettingsSection>
);
}
function SkillSettingsList({
skills,
onClose,
}: {
skills: Skill[];
onClose?: () => void;
}) {
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("all");
const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// Visible-to-me skills, classified into the three buckets the UI cares about.
// "All" = built-in + anyone-published-custom + mine (active state only).
// "Mine" = strictly my own custom skills (active state only).
// 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
const all: Skill[] = [];
const mine: Skill[] = [];
@@ -115,8 +111,6 @@ function SkillSettingsList({
const isOwnedByMe =
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isPublishedByOthers =
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
if (state === "stale") {
stale.push(skill);
@@ -127,7 +121,7 @@ function SkillSettingsList({
continue;
}
// state === active
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
if (isBuiltin || !!skill.published) {
all.push(skill);
}
if (isOwnedByMe) {
@@ -156,8 +150,8 @@ function SkillSettingsList({
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
</TabsList>
</Tabs>
<Button
@@ -211,10 +205,22 @@ function SkillSettingsList({
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="max-w-xl"
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<SkillRow
key={skill.name}
@@ -258,6 +264,8 @@ function SkillRow({
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -308,7 +316,10 @@ function SkillRow({
return (
<>
<Item className="w-full" variant="outline">
<ItemContent>
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
@@ -392,8 +403,22 @@ function SkillRow({
: t.settings.skills.useCountInlineZero}
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</ItemContent>
<ItemActions>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
@@ -473,10 +498,64 @@ function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
)}
<SkillDetailDialog
skill={skill}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</>
);
}
function SkillDetailDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{skill.description}</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive p-4 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : (
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{data?.content}
</pre>
)}
</div>
</DialogContent>
</Dialog>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -1,5 +1,18 @@
"use client";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Item,
ItemActions,
@@ -8,16 +21,54 @@ import {
ItemTitle,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
import {
useDeleteMCPServer,
useEnableMCPServer,
useMCPConfig,
} from "@/core/mcp/hooks";
import type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env";
import { MCPServerEditDialog } from "./mcp-server-edit-dialog";
import { SettingsSection } from "./settings-section";
export function ToolSettingsPage() {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
const isAdmin = getStoredAuth()?.system_role === "admin";
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const [editOpen, setEditOpen] = useState(false);
const [editMode, setEditMode] = useState<"create" | "edit">("create");
const [editingName, setEditingName] = useState<string | undefined>(undefined);
const [editingServer, setEditingServer] = useState<
MCPServerConfig | undefined
>(undefined);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const servers = useMemo(() => {
if (!config) return [] as Array<[string, MCPServerConfig]>;
return Object.entries(config.mcp_servers);
}, [config]);
const existingNames = useMemo(() => servers.map(([n]) => n), [servers]);
const handleCreate = () => {
setEditMode("create");
setEditingName(undefined);
setEditingServer(undefined);
setEditOpen(true);
};
const handleEdit = (name: string, server: MCPServerConfig) => {
setEditMode("edit");
setEditingName(name);
setEditingServer(server);
setEditOpen(true);
};
return (
<SettingsSection
title={t.settings.tools.title}
@@ -26,38 +77,128 @@ export function ToolSettingsPage() {
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
<div className="text-destructive text-sm">Error: {error.message}</div>
) : (
config && <MCPServerList servers={config.mcp_servers} />
<div className="flex w-full flex-col gap-3">
{isAdmin && (
<div className="flex justify-end">
<Button
size="sm"
onClick={handleCreate}
disabled={isStaticMode}
>
<Plus className="size-4" />
新增 MCP 服务器
</Button>
</div>
)}
<MCPServerList
servers={servers}
isAdmin={isAdmin}
isStaticMode={isStaticMode}
onEdit={handleEdit}
onDelete={(name) => setDeleteTarget(name)}
/>
</div>
)}
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
</SettingsSection>
);
}
function MCPServerList({
servers,
isAdmin,
isStaticMode,
onEdit,
onDelete,
}: {
servers: Record<string, MCPServerConfig>;
servers: Array<[string, MCPServerConfig]>;
isAdmin: boolean;
isStaticMode: boolean;
onEdit: (name: string, server: MCPServerConfig) => void;
onDelete: (name: string) => void;
}) {
const { mutate: enableMCPServer } = useEnableMCPServer();
if (servers.length === 0) {
return (
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
{isAdmin
? "还没有配置任何 MCP 服务器,点上面的按钮添加一个。"
: "管理员还没有配置任何 MCP 服务器。"}
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
{Object.entries(servers).map(([name, config]) => (
{servers.map(([name, config]) => (
<Item className="w-full" variant="outline" key={name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">
<div>{name}</div>
<div className="flex flex-wrap items-center gap-2">
<span>{name}</span>
<Badge variant="outline" className="text-xs">
{config.type ?? "stdio"}
</Badge>
{config.disabled_tools &&
config.disabled_tools.length > 0 && (
<Badge variant="secondary" className="text-xs">
已禁用 {config.disabled_tools.length} 个工具
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{config.description}
{config.description ||
(config.type === "stdio"
? config.command
: config.url) ||
"暂无描述"}
</ItemDescription>
</ItemContent>
<ItemActions>
<ItemActions className="flex items-center gap-2">
{isAdmin && (
<>
<Button
size="icon"
variant="ghost"
className="size-8"
onClick={() => onEdit(name, config)}
disabled={isStaticMode}
aria-label="编辑"
>
<Pencil className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8"
onClick={() => onDelete(name)}
disabled={isStaticMode}
aria-label="删除"
>
<Trash2 className="size-4" />
</Button>
</>
)}
<Switch
checked={config.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
disabled={isStaticMode || !isAdmin}
onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked })
}
@@ -68,3 +209,49 @@ function MCPServerList({
</div>
);
}
function DeleteConfirmDialog({
target,
onOpenChange,
}: {
target: string | null;
onOpenChange: (open: boolean) => void;
}) {
const deleteMcp = useDeleteMCPServer();
const handleConfirm = async () => {
if (!target) return;
try {
await deleteMcp.mutateAsync({ serverName: target });
toast.success(`已删除 MCP 服务器:${target}`);
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={target !== null} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>删除 MCP 服务器</DialogTitle>
</DialogHeader>
<div className="py-2 text-sm">
确定要删除 MCP 服务器 <b>{target}</b> 吗?该操作不可撤销。
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={deleteMcp.isPending}
>
{deleteMcp.isPending ? "删除中..." : "删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -154,6 +154,73 @@ function StatusCard() {
</span>
</span>
</StatRow>
<StatRow label={tt.lastActions} className="sm:col-span-2">
<div className="flex flex-col gap-2 text-xs">
<div>
<div className="font-medium">{tt.llmSuggested}</div>
{status.last_consolidation_suggestions.consolidations.length ===
0 &&
status.last_consolidation_suggestions.prunings.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
{status.last_consolidation_suggestions.consolidations.map(
(c, i) => (
<li key={`c${i}`}>
{(c.from ?? "?") + " → " + (c.into ?? "?")}
{c.reason ? `(${c.reason})` : ""}
</li>
),
)}
{status.last_consolidation_suggestions.prunings.map(
(p, i) => (
<li key={`p${i}`}>
{p.name ?? "?"}
{p.reason ? `(${p.reason})` : ""}
</li>
),
)}
</ul>
)}
</div>
<div>
<div className="font-medium">{tt.actuallyDid}</div>
{status.last_actual_actions.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="mt-0.5 flex flex-col gap-1">
{status.last_actual_actions.map((a, i) => (
<li
key={i}
className="flex flex-wrap items-center gap-1.5"
>
<span className="font-mono">{a.tool ?? "?"}</span>
{typeof a.args?.name === "string" && (
<span className="text-muted-foreground">
{String(a.args.name)}
</span>
)}
<Badge
variant={
a.status === "error"
? "destructive"
: "secondary"
}
className="px-1 text-[10px]"
>
{a.status}
</Badge>
</li>
))}
</ul>
)}
</div>
</div>
</StatRow>
</div>
) : null}
</CardContent>
@@ -0,0 +1,58 @@
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Tag } from "@/core/tags";
import { resolveTagColor } from "./tag-colors";
/** A read-only (or removable) coloured chip for a single tag. */
export function TagChip({
tag,
onRemove,
className,
}: {
tag: Pick<Tag, "name" | "color">;
onRemove?: () => void;
className?: string;
}) {
const color = resolveTagColor(tag.color);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
color.chip,
className,
)}
>
{tag.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="hover:opacity-70"
aria-label={`remove ${tag.name}`}
>
<XIcon className="size-3" />
</button>
)}
</span>
);
}
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
export function TagChipList({
tags,
className,
}: {
tags: Pick<Tag, "name" | "color">[];
className?: string;
}) {
if (!tags || tags.length === 0) return null;
return (
<div className={cn("flex flex-wrap gap-1", className)}>
{tags.map((tag) => (
<TagChip key={tag.name} tag={tag} />
))}
</div>
);
}
@@ -0,0 +1,30 @@
// Fixed tag color palette. The backend only stores the color *name*; the
// Tailwind classes are resolved here so the class strings stay static and
// survive the production purge.
export interface TagColorDef {
name: string;
/** Classes for a rendered chip (background + text + border). */
chip: string;
/** A solid swatch used in the colour picker. */
swatch: string;
}
export const TAG_COLORS: TagColorDef[] = [
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
];
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
export function resolveTagColor(name: string | undefined | null): TagColorDef {
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
}
@@ -0,0 +1,140 @@
"use client";
import { CheckIcon, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
import { resolveTagColor } from "./tag-colors";
/**
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
* unbinds it immediately (no separate save step). Only tags of ``targetType``
* are offered, enforcing "a tag may only tag its own type".
*/
export function TagPickerDialog({
open,
onOpenChange,
targetType,
targetId,
currentTags,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetType: TagType;
targetId: string;
currentTags: Tag[];
}) {
const { t } = useI18n();
const { data: allTags, isLoading } = useTags(targetType);
const setResourceTags = useSetResourceTags();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${targetId}`;
if (open && syncedFor !== syncKey) {
setSelected(new Set(currentTags.map((tg) => tg.id)));
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
// Clicking a tag mutates immediately. Local state updates optimistically and
// rolls back if the request fails.
const toggle = (tagId: string) => {
const prev = selected;
const next = new Set(prev);
if (next.has(tagId)) next.delete(tagId);
else next.add(tagId);
setSelected(next);
setResourceTags.mutate(
{ targetType, targetId, tagIds: [...next] },
{
onError: (e) => {
setSelected(prev);
toast.error(String(e instanceof Error ? e.message : e));
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
</DialogHeader>
<div className="min-h-[120px]">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{!isLoading && (!allTags || allTags.length === 0) && (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.adminTags.pickerEmpty}
</div>
)}
{!isLoading && allTags && allTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const checked = selected.has(tag.id);
const color = resolveTagColor(tag.color);
return (
<button
key={tag.id}
type="button"
onClick={() => toggle(tag.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
color.chip,
checked
? "ring-2 ring-offset-1 ring-foreground/40"
: "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3.5" />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="items-center sm:justify-between">
<span className="text-muted-foreground text-xs">
{setResourceTags.isPending ? (
<span className="flex items-center gap-1">
<Loader2 className="size-3 animate-spin" />
{t.adminTags.saving}
</span>
) : (
t.adminTags.pickerSelectedCount.replace(
"{count}",
String(selected.size),
)
)}
</span>
<Button onClick={() => onOpenChange(false)}>
{t.adminTags.done}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,80 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
const ALL = "__all__";
/**
* Reusable name-search + tag-filter toolbar for resource list pages.
*
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a
* request per keystroke. Filtering is open to all users.
*/
export function TagSearchBar({
targetType,
onSearchChange,
tagId,
onTagIdChange,
className,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagId: string | null;
onTagIdChange: (value: string | null) => void;
className?: string;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
const [input, setInput] = useState("");
const cbRef = useRef(onSearchChange);
cbRef.current = onSearchChange;
useEffect(() => {
const id = setTimeout(() => cbRef.current(input.trim()), 300);
return () => clearTimeout(id);
}, [input]);
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
</div>
<Select
value={tagId ?? ALL}
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder={t.adminTags.filterByTag} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
{(tags ?? []).map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -1,6 +1,15 @@
"use client";
import { ChevronsUpDown, MessageSquarePlusIcon, Settings2Icon, SettingsIcon } from "lucide-react";
import {
BellIcon,
ChevronsUpDown,
GaugeIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
TagsIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -19,8 +28,9 @@ import {
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import { NotificationBell } from "./notification-bell";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -50,11 +60,13 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "notification" | "about"
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
@@ -67,10 +79,11 @@ export function WorkspaceNavMenu() {
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
<NotificationBell />
</SidebarMenuItem>
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
@@ -88,6 +101,15 @@ export function WorkspaceNavMenu() {
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
@@ -107,6 +129,34 @@ export function WorkspaceNavMenu() {
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
+13 -2
View File
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
}
}
export async function listAgents(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
export interface ListAgentsParams {
search?: string;
tagId?: string | null;
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
return data.agents;
+12 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createAgent,
@@ -9,10 +14,13 @@ import {
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
export function useAgents() {
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["agents"],
queryFn: () => listAgents(),
queryKey: ["agents", search, tagId],
queryFn: () => listAgents({ search, tagId }),
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
}
+3
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export interface Agent {
id: string;
name: string;
@@ -11,6 +13,7 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
tags?: Tag[];
}
export interface CreateAgentRequest {
+40
View File
@@ -0,0 +1,40 @@
import { apiFetch } from "@/core/api";
import { urlOfArtifact } from "./utils";
async function errorMessageFromResponse(res: Response): Promise<string> {
try {
const data: unknown = await res.json();
if (data && typeof data === "object" && "detail" in data) {
const detail = (data as { detail: unknown }).detail;
if (typeof detail === "string") return detail;
}
} catch {
// ignore non-json errors
}
return res.statusText || "Request failed";
}
export async function updateArtifactContent({
filepath,
threadId,
content,
isMock,
}: {
filepath: string;
threadId: string;
content: string;
isMock?: boolean;
}) {
const res = await apiFetch(urlOfArtifact({ filepath, threadId, isMock }), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
return res.text();
}
+101 -16
View File
@@ -1,9 +1,65 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { updateArtifactContent } from "./api";
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
if (!toolCallId) {
return false;
}
return thread.messages.some(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
}
function applyPendingTextEdit({
content,
filepath,
thread,
}: {
content: string | undefined;
filepath: string;
thread: ReturnType<typeof useThread>["thread"];
}) {
if (!content || !thread.isLoading) {
return null;
}
for (const message of [...thread.messages].reverse()) {
if (message.type !== "ai" || !message.tool_calls) {
continue;
}
for (const toolCall of [...message.tool_calls].reverse()) {
if (toolCall.name !== "str_replace" || hasToolResult(thread, toolCall.id)) {
continue;
}
const args = toolCall.args as {
path?: unknown;
old_str?: unknown;
new_str?: unknown;
replace_all?: unknown;
};
if (args.path !== filepath) {
continue;
}
if (typeof args.old_str !== "string" || typeof args.new_str !== "string") {
continue;
}
if (!content.includes(args.old_str)) {
return { content, kind: "editing" as const };
}
const nextContent =
args.replace_all === true
? content.split(args.old_str).join(args.new_str)
: content.replace(args.old_str, args.new_str);
return { content: nextContent, kind: "editing" as const };
}
}
return null;
}
export function useArtifactContent({
filepath,
@@ -25,19 +81,48 @@ export function useArtifactContent({
return null;
}, [filepath, isWriteFile, thread]);
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock });
},
enabled,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
});
return {
content: isWriteFile ? content : data?.content,
url: isWriteFile ? undefined : data?.url,
isLoading,
error,
};
}
enabled,
refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
});
const pendingEdit = useMemo(() => {
if (isWriteFile) {
return null;
}
return applyPendingTextEdit({
content: data?.content,
filepath,
thread,
});
}, [data?.content, filepath, isWriteFile, thread]);
return {
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
url: isWriteFile ? undefined : data?.url,
isLoading,
isApplyingEdit: pendingEdit?.kind === "editing",
error,
};
}
export function useUpdateArtifactContent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateArtifactContent,
onSuccess: (content, variables) => {
queryClient.setQueryData(
["artifact", variables.filepath, variables.threadId, variables.isMock],
{
content,
url: undefined,
},
);
},
});
}
+3 -2
View File
@@ -1,2 +1,3 @@
export * from "./download";
export * from "./loader";
export * from "./api";
export * from "./download";
export * from "./loader";
+27
View File
@@ -11,6 +11,29 @@ export interface CuratorConfig {
curator: CuratorSubConfig;
}
export interface CuratorAction {
tool: string | null;
args: Record<string, unknown>;
status: string;
result: string;
}
export interface CuratorConsolidation {
from?: string;
into?: string;
reason?: string;
}
export interface CuratorPruning {
name?: string;
reason?: string;
}
export interface CuratorSuggestions {
consolidations: CuratorConsolidation[];
prunings: CuratorPruning[];
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
@@ -23,6 +46,10 @@ export interface CuratorStatus {
stale: number;
archived: number;
};
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
last_consolidation_suggestions: CuratorSuggestions;
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
last_actual_actions: CuratorAction[];
}
export interface CuratorLogRecord {
+42 -1
View File
@@ -456,7 +456,7 @@ export const enUS: Translations = {
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded",
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
tabAll: "All",
tabAll: "Square",
tabMine: "Mine",
entryStale: "Stale {count}",
entryArchived: "Archived {count}",
@@ -557,6 +557,10 @@ export const enUS: Translations = {
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
lastActions: "Last run",
llmSuggested: "LLM suggested (self-report)",
actuallyDid: "Actually done (tool calls)",
noActions: "None",
},
config: {
title: "Configuration",
@@ -625,6 +629,40 @@ export const enUS: Translations = {
empty: "No users found",
},
adminTags: {
title: "Tag Management",
description: "Create and manage tags for agents, skills, and scheduled tasks. A tag may only be applied to resources of its own type.",
typeAgent: "Agent Tags",
typeSkill: "Skill Tags",
typeScheduledTask: "Scheduled Task Tags",
name: "Name",
color: "Color",
tagDescription: "Description",
usageCount: "Usage",
actions: "Actions",
empty: "No tags yet",
createButton: "New Tag",
createTitle: "Create Tag",
editTitle: "Edit Tag",
deleteConfirm: "Delete this tag? It will be removed from every resource it tags.",
namePlaceholder: "Tag name",
descriptionPlaceholder: "Optional description",
save: "Save",
cancel: "Cancel",
edit: "Edit",
delete: "Delete",
pickerTitle: "Edit Tags",
pickerHint: "Click a tag to bind or unbind it — changes are saved instantly.",
pickerEmpty: "No tags available for this type. Create some in Tag Management.",
pickerSelectedCount: "{count} selected",
saving: "Saving…",
done: "Done",
manageTags: "Tags",
searchPlaceholder: "Search by name",
filterByTag: "Filter by tag",
allTags: "All tags",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
@@ -632,5 +670,8 @@ export const enUS: Translations = {
markAllRead: "Mark all read",
empty: "No notifications",
deleteAria: "Delete notification",
settingsAria: "Notification settings",
backAria: "Back",
settingsTitle: "Notification settings",
},
};
@@ -7,6 +7,9 @@ export interface NotificationsTranslations {
markAllRead: string;
empty: string;
deleteAria: string;
settingsAria: string;
backAria: string;
settingsTitle: string;
}
export interface Translations {
@@ -479,6 +482,10 @@ export interface Translations {
total: string;
runNow: string;
runStarted: string;
lastActions: string;
llmSuggested: string;
actuallyDid: string;
noActions: string;
};
config: {
title: string;
@@ -538,6 +545,43 @@ export interface Translations {
empty: string;
};
// Admin tag management page
adminTags: {
title: string;
description: string;
typeAgent: string;
typeSkill: string;
typeScheduledTask: string;
name: string;
color: string;
tagDescription: string;
usageCount: string;
actions: string;
empty: string;
createButton: string;
createTitle: string;
editTitle: string;
deleteConfirm: string;
namePlaceholder: string;
descriptionPlaceholder: string;
save: string;
cancel: string;
edit: string;
delete: string;
// tag picker dialog
pickerTitle: string;
pickerHint: string;
pickerEmpty: string;
pickerSelectedCount: string;
saving: string;
done: string;
manageTags: string;
// search controls on resource lists
searchPlaceholder: string;
filterByTag: string;
allTags: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
+44 -3
View File
@@ -1,4 +1,4 @@
import {
import {
CompassIcon,
GraduationCapIcon,
ImageIcon,
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传",
badgeSourceUploadTitle: "由用户手动上传或编辑",
tabAll: "全部",
tabAll: "广场",
tabMine: "我的",
entryStale: "过时 {count}",
entryArchived: "归档 {count}",
@@ -452,7 +452,7 @@ export const zhCN: Translations = {
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
stateSheet: {
archivedTitle: "归档的技能",
@@ -533,6 +533,10 @@ export const zhCN: Translations = {
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
lastActions: "最近一次运行",
llmSuggested: "LLM 建议(自述)",
actuallyDid: "实际执行(工具调用)",
noActions: "无",
},
config: {
title: "配置",
@@ -594,6 +598,40 @@ export const zhCN: Translations = {
empty: "暂无用户",
},
adminTags: {
title: "标签管理",
description: "为智能体、技能和定时任务创建并管理标签。标签只能用于其所属类型的资源。",
typeAgent: "智能体标签",
typeSkill: "技能标签",
typeScheduledTask: "定时任务标签",
name: "名称",
color: "颜色",
tagDescription: "描述",
usageCount: "使用数",
actions: "操作",
empty: "暂无标签",
createButton: "新建标签",
createTitle: "创建标签",
editTitle: "编辑标签",
deleteConfirm: "确认删除此标签?它将从所有已打标签的资源上移除。",
namePlaceholder: "标签名称",
descriptionPlaceholder: "描述(可选)",
save: "保存",
cancel: "取消",
edit: "编辑",
delete: "删除",
pickerTitle: "编辑标签",
pickerHint: "点击标签即可绑定或解绑,修改实时保存。",
pickerEmpty: "该类型暂无可用标签,请先在标签管理中创建。",
pickerSelectedCount: "已选 {count} 个",
saving: "保存中…",
done: "完成",
manageTags: "标签",
searchPlaceholder: "按名称搜索",
filterByTag: "按标签筛选",
allTags: "全部标签",
},
notifications: {
title: "通知",
bellAria: "打开通知",
@@ -601,5 +639,8 @@ export const zhCN: Translations = {
markAllRead: "全部已读",
empty: "暂无通知",
deleteAria: "删除通知",
settingsAria: "通知设置",
backAria: "返回",
settingsTitle: "通知设置",
},
};
+98
View File
@@ -0,0 +1,98 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface LlmMetric {
id: string;
created_at?: string | null;
user_id?: string | null;
user_name?: string | null;
thread_id?: string | null;
run_id?: string | null;
agent_name?: string | null;
model_name?: string | null;
duration_ms: number;
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
tokens_per_sec?: number | null;
status: "success" | "error" | string;
error_type?: string | null;
error_message?: string | null;
}
export interface LlmMetricsListResponse {
items: LlmMetric[];
total: number;
limit: number;
offset: number;
}
export interface LlmMetricsFilters {
user_id?: string;
user_name?: string;
model_name?: string;
status?: "success" | "error";
since?: string;
until?: string;
}
export interface LlmMetricsListQuery extends LlmMetricsFilters {
limit?: number;
offset?: number;
}
function buildQuery(filters: LlmMetricsListQuery): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === "") continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export async function loadLlmMetrics(
query: LlmMetricsListQuery,
): Promise<LlmMetricsListResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics${buildQuery(query)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to load LLM metrics");
}
return (await response.json()) as LlmMetricsListResponse;
}
/**
* Trigger the Excel download. Returns the blob URL used so callers can revoke
* it if they want; the function itself handles the click and cleanup.
*/
export async function downloadLlmMetricsExcel(
filters: LlmMetricsFilters,
): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics/export${buildQuery(filters)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to export LLM metrics");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = (() => {
const header = response.headers.get("content-disposition") ?? "";
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain?.[1] ?? `llm_call_metrics.xlsx`;
})();
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { loadLlmMetrics, type LlmMetricsListQuery } from "./api";
export function useLlmMetrics(query: LlmMetricsListQuery) {
return useQuery({
queryKey: ["llmMetrics", query],
queryFn: () => loadLlmMetrics(query),
refetchOnWindowFocus: false,
placeholderData: (previous) => previous,
});
}
+42
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { loadMCPConfig, updateMCPConfig } from "./api";
import type { MCPServerConfig } from "./types";
export function useMCPConfig() {
const { data, isLoading, error } = useQuery({
@@ -42,3 +43,44 @@ export function useEnableMCPServer() {
},
});
}
export function useUpsertMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({
serverName,
server,
originalName,
}: {
serverName: string;
server: MCPServerConfig;
originalName?: string;
}) => {
const next = { ...(config?.mcp_servers ?? {}) };
if (originalName && originalName !== serverName) {
delete next[originalName];
}
next[serverName] = server;
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
export function useDeleteMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({ serverName }: { serverName: string }) => {
const next = { ...(config?.mcp_servers ?? {}) };
delete next[serverName];
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
+28 -1
View File
@@ -1,6 +1,33 @@
export interface MCPServerConfig extends Record<string, unknown> {
export type MCPServerTransport = "stdio" | "http" | "sse";
export interface MCPOAuthConfig {
enabled?: boolean;
token_url?: string;
grant_type?: "client_credentials" | "refresh_token";
client_id?: string | null;
client_secret?: string | null;
refresh_token?: string | null;
scope?: string | null;
audience?: string | null;
token_field?: string;
token_type_field?: string;
expires_in_field?: string;
default_token_type?: string;
refresh_skew_seconds?: number;
extra_token_params?: Record<string, string>;
}
export interface MCPServerConfig {
enabled: boolean;
type?: MCPServerTransport;
command?: string | null;
args?: string[];
env?: Record<string, string>;
url?: string | null;
headers?: Record<string, string>;
oauth?: MCPOAuthConfig | null;
description: string;
disabled_tools?: string[];
}
export interface MCPConfig {
+13 -2
View File
@@ -28,8 +28,19 @@ export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
export async function listScheduledTasks() {
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
export interface ListScheduledTasksParams {
search?: string;
tagId?: string | null;
}
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
return parseResponse<ScheduledTask[]>(
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
);
}
export async function createScheduledTask(data: {
+14 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createScheduledTask,
@@ -30,11 +35,16 @@ export function useSchedulerThread() {
});
}
export function useScheduledTasks() {
export function useScheduledTasks(
params: { search?: string; tagId?: string | null } = {},
) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: listScheduledTasks,
queryKey: ["scheduled-tasks", "list", search, tagId],
queryFn: () => listScheduledTasks({ search, tagId }),
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
}
@@ -1,104 +1,181 @@
/**
* Convert markdown text to a Word (.docx) Blob in the browser.
*
* Implementation notes:
* - Uses the `docx` npm package to construct an OOXML document client-side.
* - Parser is intentionally lightweight: it covers the markdown features that
* scheduled-task agent reports actually emit — ATX headings, paragraphs,
* ordered/unordered lists, fenced code blocks, blockquotes, horizontal
* rules, and inline `**bold**` / `*italic*` / `` `code` `` runs.
* - We deliberately do NOT reach for a full markdown AST library (remark/
* unified) to keep the bundle small. Edge cases (tables, footnotes, html)
* degrade to plain text rather than failing loudly.
* Designed for scheduled-task reports that often contain Chinese text,
* markdown tables, and emojis. Compared to Word's stock heading styles we
* deliberately:
* - Force every run color to pure black (`#000000`) so headings, links,
* and inline code never render as Word's default blue / hyperlink theme.
* - Render markdown tables as real OOXML tables (full-width, auto-wrapping)
* instead of falling back to plain text rows that overflow the page.
* - Strip variation selectors, zero-width joiners, and other invisible
* characters that often break Word renderers when emojis leak into the
* report.
*
* Block grammar handled line-by-line:
* - ATX heading: `^(#{1,6})\s+(.*)$`
* - Fenced code: lines between ``` ``` are emitted verbatim in a mono font
* - Blockquote: `^>\s?(.*)$`
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
* - Unordered list: `^\s*[-*+]\s+(.*)$`
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
* - Pipe table: contiguous lines starting with `|` plus a separator row
* - Blank line: paragraph break
* - Anything else: paragraph (with inline formatting)
*
* Inline grammar handled:
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
*/
import {
AlignmentType,
BorderStyle,
Document,
HeadingLevel,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
type IRunOptions,
} from "docx";
const HEADING_LEVELS = [
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
];
const BLACK = "000000";
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean };
const HEADING_SIZES_HALF_PT: Record<number, number> = {
// Word "size" property is half-points. 32 = 16pt.
1: 36,
2: 32,
3: 28,
4: 26,
5: 24,
6: 22,
};
const BODY_SIZE_HALF_PT = 22; // 11pt
type InlineSegment = {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
};
/**
* Drop characters that Word renders inconsistently or that frequently smuggle
* emoji glitches into corporate reports.
*
* Removes:
* - Variation selectors (FE00–FE0F, E0100–E01EF)
* - Zero-width joiner / non-joiner / space (200C, 200D, FEFF, 200B)
* - Soft hyphen (00AD)
* - Object replacement character (FFFC)
*/
function sanitizeText(text: string): string {
return text
.replace(/[︀-️]/g, "")
.replace(/[​-‍]/g, "")
.replace(/­/g, "")
.replace(//g, "")
.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
}
/**
* Tokenise a single line of markdown into formatted segments.
*
* Recognised inline syntax:
* `code`, **bold**, __bold__, *italic*, _italic_
* Order matters: code spans win over emphasis so backticks don't get parsed
* as italic delimiters.
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
* Markdown links are flattened to their text content — we deliberately do not
* emit hyperlink runs because they pick up Word's blue theme color.
*/
function parseInlineSegments(text: string): InlineSegment[] {
const cleaned = sanitizeText(text);
// Strip markdown link syntax first so the regex below doesn't trip on `[...]`.
const withoutLinks = cleaned.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
const tokens: InlineSegment[] = [];
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
const pattern =
/(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
while ((match = pattern.exec(withoutLinks)) !== null) {
if (match.index > cursor) {
tokens.push({ text: text.slice(cursor, match.index) });
tokens.push({ text: withoutLinks.slice(cursor, match.index) });
}
if (match[2] !== undefined) {
tokens.push({ text: match[2], code: true });
} else if (match[3] !== undefined) {
tokens.push({ text: match[3], bold: true });
} else if (match[4] !== undefined) {
tokens.push({ text: match[4], bold: true });
} else if (match[5] !== undefined) {
if (match[2] !== undefined) tokens.push({ text: match[2], code: true });
else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true });
else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true });
else if (match[5] !== undefined)
tokens.push({ text: match[5], italic: true });
} else if (match[6] !== undefined) {
else if (match[6] !== undefined)
tokens.push({ text: match[6], italic: true });
}
cursor = pattern.lastIndex;
}
if (cursor < text.length) {
tokens.push({ text: text.slice(cursor) });
if (cursor < withoutLinks.length) {
tokens.push({ text: withoutLinks.slice(cursor) });
}
return tokens.length > 0 ? tokens : [{ text }];
return tokens.length > 0 ? tokens : [{ text: withoutLinks }];
}
function segmentsToRuns(segments: InlineSegment[], extra?: Partial<IRunOptions>): TextRun[] {
function segmentsToRuns(
segments: InlineSegment[],
extra?: Partial<IRunOptions>,
): TextRun[] {
return segments.map((seg) => {
const opts: IRunOptions = {
text: seg.text,
bold: seg.bold,
italics: seg.italic,
color: BLACK,
font: seg.code ? MONO_FONT : BODY_FONT,
size: extra?.size ?? BODY_SIZE_HALF_PT,
...extra,
};
if (seg.code) {
opts.font = { name: "Consolas" };
}
return new TextRun(opts);
});
}
function makeParagraphFromInline(text: string): Paragraph {
return new Paragraph({
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text)),
});
}
function makeHeadingParagraph(level: number, text: string): Paragraph {
const clamped = Math.max(1, Math.min(level, 6));
return new Paragraph({
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
children: segmentsToRuns(parseInlineSegments(text)),
// We attach a heading level so the table-of-contents still works, but the
// run-level color / font / size below override Word's blue theme.
heading:
({
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
5: HeadingLevel.HEADING_5,
6: HeadingLevel.HEADING_6,
}[clamped] as (typeof HeadingLevel)[keyof typeof HeadingLevel]) ?? undefined,
spacing: { before: 200, after: 120 },
children: segmentsToRuns(parseInlineSegments(text), {
bold: true,
color: BLACK,
size: HEADING_SIZES_HALF_PT[clamped] ?? BODY_SIZE_HALF_PT,
}),
});
}
function makeListItem(text: string, ordered: boolean, level: number): Paragraph {
function makeListItem(
text: string,
ordered: boolean,
level: number,
): Paragraph {
return new Paragraph({
spacing: { after: 80 },
children: segmentsToRuns(parseInlineSegments(text)),
numbering: ordered
? { reference: "ordered-list", level: Math.min(level, 8) }
@@ -109,10 +186,13 @@ function makeListItem(text: string, ordered: boolean, level: number): Paragraph
function makeCodeParagraph(line: string): Paragraph {
return new Paragraph({
spacing: { after: 0 },
children: [
new TextRun({
text: line || " ",
font: { name: "Consolas" },
text: sanitizeText(line) || " ",
font: MONO_FONT,
color: BLACK,
size: BODY_SIZE_HALF_PT,
}),
],
});
@@ -120,45 +200,124 @@ function makeCodeParagraph(line: string): Paragraph {
function makeQuoteParagraph(text: string): Paragraph {
return new Paragraph({
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
indent: { left: 720 },
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
});
}
function makeHorizontalRule(): Paragraph {
return new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "────────" })],
spacing: { before: 120, after: 120 },
children: [
new TextRun({
text: "────────",
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
}),
],
});
}
const CELL_BORDER = {
style: BorderStyle.SINGLE,
size: 4,
color: "BFBFBF",
};
function makeTableCell(
text: string,
options: { header?: boolean } = {},
): TableCell {
const segments = parseInlineSegments(text);
return new TableCell({
margins: { top: 80, bottom: 80, left: 100, right: 100 },
children: [
new Paragraph({
spacing: { after: 0 },
children: segmentsToRuns(segments, {
bold: options.header || undefined,
size: BODY_SIZE_HALF_PT,
}),
}),
],
});
}
function splitTableRow(line: string): string[] {
// Strip leading/trailing pipe and any escaped pipes (rare in agent output).
const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "");
return trimmed.split("|").map((cell) => cell.trim());
}
function buildTable(headerLine: string, bodyLines: string[]): Table {
const headerCells = splitTableRow(headerLine);
const colCount = headerCells.length;
const rows: TableRow[] = [];
rows.push(
new TableRow({
tableHeader: true,
children: headerCells.map((cell) =>
makeTableCell(cell, { header: true }),
),
}),
);
for (const row of bodyLines) {
const cells = splitTableRow(row);
// Pad/truncate to match the header column count so Word renders evenly.
const normalized = Array.from(
{ length: colCount },
(_, i) => cells[i] ?? "",
);
rows.push(
new TableRow({
children: normalized.map((c) => makeTableCell(c)),
}),
);
}
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows,
borders: {
top: CELL_BORDER,
bottom: CELL_BORDER,
left: CELL_BORDER,
right: CELL_BORDER,
insideHorizontal: CELL_BORDER,
insideVertical: CELL_BORDER,
},
});
}
function isTableSeparator(line: string): boolean {
// e.g. |---|:---:|---:| with optional spaces
return /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
}
function looksLikeTableRow(line: string): boolean {
return /^\s*\|.*\|\s*$/.test(line) || /^\s*\|.+/.test(line);
}
/**
* Convert markdown text into an array of docx Paragraph objects.
*
* Block grammar handled (line-by-line):
* - ATX heading: `^(#{1,6})\s+(.*)$`
* - Fenced code: lines between ``` ``` are emitted verbatim
* - Blockquote: `^>\s?(.*)$`
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
* - Unordered list: `^\s*[-*+]\s+(.*)$`
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
* - Blank line: paragraph break
* - Anything else: paragraph (with inline formatting)
* Convert markdown text into an array of docx block elements (Paragraph | Table).
*/
function markdownToParagraphs(markdown: string): Paragraph[] {
function markdownToBlocks(markdown: string): Array<Paragraph | Table> {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const paragraphs: Paragraph[] = [];
const blocks: Array<Paragraph | Table> = [];
let inFence = false;
let paragraphBuffer: string[] = [];
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
blocks.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
paragraphBuffer = [];
};
for (const rawLine of lines) {
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i] ?? "";
const line = rawLine.replace(/\t/g, " ");
if (line.trim().startsWith("```")) {
@@ -167,7 +326,7 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
continue;
}
if (inFence) {
paragraphs.push(makeCodeParagraph(line));
blocks.push(makeCodeParagraph(line));
continue;
}
@@ -179,36 +338,63 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
blocks.push(
makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()),
);
continue;
}
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
flushParagraph();
paragraphs.push(makeHorizontalRule());
blocks.push(makeHorizontalRule());
continue;
}
const blockquote = /^>\s?(.*)$/.exec(line);
if (blockquote) {
flushParagraph();
paragraphs.push(makeQuoteParagraph(blockquote[1]));
blocks.push(makeQuoteParagraph(blockquote[1] ?? ""));
continue;
}
// Table detection — a header row followed by a separator row.
if (
looksLikeTableRow(line) &&
i + 1 < lines.length &&
isTableSeparator(lines[i + 1] ?? "")
) {
flushParagraph();
const headerLine = line;
i += 2; // skip header + separator
const body: string[] = [];
while (i < lines.length && looksLikeTableRow(lines[i] ?? "")) {
body.push(lines[i] ?? "");
i++;
}
// Re-step back one so the outer loop's i++ lands on the next unprocessed
// line. Without this, blank lines after the table get swallowed.
i--;
blocks.push(buildTable(headerLine, body));
// Word's table rendering eats following spacing; inject a tiny gap.
blocks.push(
new Paragraph({ spacing: { after: 0 }, children: [new TextRun("")] }),
);
continue;
}
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
if (orderedItem) {
flushParagraph();
const indent = Math.floor(orderedItem[1].length / 2);
paragraphs.push(makeListItem(orderedItem[3], true, indent));
const indent = Math.floor(orderedItem[1]!.length / 2);
blocks.push(makeListItem(orderedItem[3]!, true, indent));
continue;
}
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (unorderedItem) {
flushParagraph();
const indent = Math.floor(unorderedItem[1].length / 2);
paragraphs.push(makeListItem(unorderedItem[2], false, indent));
const indent = Math.floor(unorderedItem[1]!.length / 2);
blocks.push(makeListItem(unorderedItem[2]!, false, indent));
continue;
}
@@ -216,33 +402,70 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
}
flushParagraph();
return paragraphs;
return blocks;
}
/**
* Convert a markdown string into a .docx Blob suitable for download.
*/
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
const children: Paragraph[] = [];
export async function markdownToDocxBlob(
markdown: string,
title?: string,
): Promise<Blob> {
const children: Array<Paragraph | Table> = [];
if (title?.trim()) {
children.push(
new Paragraph({
heading: HeadingLevel.TITLE,
children: [new TextRun({ text: title.trim(), bold: true })],
spacing: { before: 0, after: 240 },
children: [
new TextRun({
text: sanitizeText(title.trim()),
bold: true,
color: BLACK,
font: BODY_FONT,
size: 40, // 20pt
}),
],
}),
);
}
children.push(...markdownToParagraphs(markdown));
children.push(...markdownToBlocks(markdown));
const doc = new Document({
styles: {
default: {
document: {
run: {
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
},
},
},
},
numbering: {
config: [
{
reference: "ordered-list",
levels: [
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.START },
{ level: 1, format: "decimal", text: "%2.", alignment: AlignmentType.START },
{ level: 2, format: "decimal", text: "%3.", alignment: AlignmentType.START },
{
level: 0,
format: "decimal",
text: "%1.",
alignment: AlignmentType.START,
},
{
level: 1,
format: "decimal",
text: "%2.",
alignment: AlignmentType.START,
},
{
level: 2,
format: "decimal",
text: "%3.",
alignment: AlignmentType.START,
},
],
},
],
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type ScheduledTaskRole = "owner" | "subscriber";
export type ScheduledTaskSubscription = {
@@ -27,6 +29,9 @@ export type ScheduledTask = {
published_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null;
creator_email?: string | null;
tags?: Tag[];
};
export type PublishedScheduledTask = {
@@ -37,6 +42,8 @@ export type PublishedScheduledTask = {
timezone: string;
published_at?: string | null;
owner_user_id: string;
creator_user_id?: string | null;
creator_email?: string | null;
};
export type SchedulerThread = {
+21 -5
View File
@@ -4,12 +4,16 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
display: {
streamSpeedMeterEnabled: false,
},
};
context: {
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.";
@@ -22,6 +26,14 @@ export interface LocalSettings {
notification: {
enabled: boolean;
};
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
* the appearance settings page.
*/
streamSpeedMeterEnabled: boolean;
};
context: Omit<
AgentThreadContext,
| "thread_id"
@@ -48,6 +60,10 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
display: {
...DEFAULT_LOCAL_SETTINGS.display,
...settings?.display,
},
};
}
+31 -2
View File
@@ -3,8 +3,19 @@ import { apiFetch } from "@/core/api";
import type { Skill, SkillState } from "./type";
export async function loadSkills() {
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
export interface ListSkillsParams {
search?: string;
tagId?: string | null;
}
export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
);
const json = await skills.json();
return json.skills as Skill[];
}
@@ -174,6 +185,24 @@ export async function setSkillPinned(
return response.json() as Promise<Skill>;
}
export interface SkillContentResponse {
name: string;
category: string;
content: string;
}
export async function getSkillContent(
skillName: string,
): Promise<SkillContentResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill content");
}
return response.json() as Promise<SkillContentResponse>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
+21 -4
View File
@@ -1,8 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deleteCustomSkill,
enableSkill,
getSkillContent,
installSkillUpload,
setSkillPinned,
setSkillPublished,
@@ -13,14 +19,25 @@ import type { SkillState } from "./type";
import { loadSkills } from ".";
export function useSkills() {
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["skills"],
queryFn: () => loadSkills(),
queryKey: ["skills", search, tagId],
queryFn: () => loadSkills({ search, tagId }),
placeholderData: keepPreviousData,
});
return { skills: data ?? [], isLoading, error };
}
export function useSkillContent(skillName: string | null) {
return useQuery({
queryKey: ["skill-content", skillName],
queryFn: () => getSkillContent(skillName!),
enabled: !!skillName,
});
}
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
+4
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
@@ -23,4 +25,6 @@ export interface Skill {
use_count?: number;
view_count?: number;
patch_count?: number;
// Tags assigned to this skill (admin-managed).
tags?: Tag[];
}
@@ -0,0 +1,44 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface PromptPrefixSettings {
enabled: boolean;
switch_label: string;
prompt_prefix: string;
}
export interface PromptPrefixUpdate {
enabled?: boolean;
switch_label?: string;
prompt_prefix?: string;
}
export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
);
if (!response.ok) {
throw new Error(`Failed to load prompt prefix settings: ${response.status}`);
}
return (await response.json()) as PromptPrefixSettings;
}
export async function updatePromptPrefixSettings(
body: PromptPrefixUpdate,
): Promise<PromptPrefixSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update prompt prefix" }));
throw new Error(detail.detail ?? "Failed to update prompt prefix");
}
return (await response.json()) as PromptPrefixSettings;
}
@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
loadPromptPrefixSettings,
updatePromptPrefixSettings,
type PromptPrefixUpdate,
} from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
export function usePromptPrefixSettings() {
return useQuery({
queryKey: PROMPT_PREFIX_KEY,
queryFn: () => loadPromptPrefixSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
}
export function useUpdatePromptPrefixSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PromptPrefixUpdate) => updatePromptPrefixSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(PROMPT_PREFIX_KEY, data);
},
});
}
+83
View File
@@ -0,0 +1,83 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
Tag,
TagCreateInput,
TagListResponse,
TagType,
TagUpdateInput,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
const base = () => `${getBackendBaseURL()}/api/tags`;
export async function listTags(tagType?: TagType): Promise<Tag[]> {
const query = tagType ? `?tag_type=${encodeURIComponent(tagType)}` : "";
const res = await apiFetch(`${base()}${query}`);
if (!res.ok) throw await parseDetail(res, "Failed to list tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function createTag(input: TagCreateInput): Promise<Tag> {
const res = await apiFetch(base(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to create tag");
return (await res.json()) as Tag;
}
export async function updateTag(id: string, input: TagUpdateInput): Promise<Tag> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to update tag");
return (await res.json()) as Tag;
}
export async function deleteTag(id: string): Promise<void> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) throw await parseDetail(res, "Failed to delete tag");
}
export async function getResourceTags(
targetType: TagType,
targetId: string,
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
);
if (!res.ok) throw await parseDetail(res, "Failed to load resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function setResourceTags(
targetType: TagType,
targetId: string,
tagIds: string[],
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_ids: tagIds }),
},
);
if (!res.ok) throw await parseDetail(res, "Failed to update resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
+88
View File
@@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createTag,
deleteTag,
getResourceTags,
listTags,
setResourceTags,
updateTag,
} from "./api";
import type { TagCreateInput, TagType, TagUpdateInput } from "./types";
// Tags are global (admin-managed), so the cache key is not user-scoped.
const tagKeys = {
root: ["tags"] as const,
list: (tagType?: TagType) => ["tags", "list", tagType ?? "all"] as const,
assignment: (targetType: TagType, targetId: string) =>
["tags", "assignment", targetType, targetId] as const,
};
export function useTags(tagType?: TagType) {
return useQuery({
queryKey: tagKeys.list(tagType),
queryFn: () => listTags(tagType),
});
}
function useInvalidateTags() {
const queryClient = useQueryClient();
return () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
};
}
export function useCreateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (input: TagCreateInput) => createTag(input),
onSuccess: invalidate,
});
}
export function useUpdateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: TagUpdateInput }) =>
updateTag(id, input),
onSuccess: invalidate,
});
}
export function useDeleteTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (id: string) => deleteTag(id),
onSuccess: invalidate,
});
}
export function useResourceTags(targetType: TagType, targetId: string, enabled = true) {
return useQuery({
queryKey: tagKeys.assignment(targetType, targetId),
queryFn: () => getResourceTags(targetType, targetId),
enabled,
});
}
export function useSetResourceTags() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
targetType,
targetId,
tagIds,
}: {
targetType: TagType;
targetId: string;
tagIds: string[];
}) => setResourceTags(targetType, targetId, tagIds),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
// Resource list pages embed the tags, so refresh them too.
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
+31
View File
@@ -0,0 +1,31 @@
export type TagType = "agent" | "skill" | "scheduled_task";
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"];
export interface Tag {
id: string;
name: string;
tag_type: TagType;
color: string;
description: string;
usage_count: number;
created_at: string | null;
updated_at: string | null;
}
export interface TagListResponse {
tags: Tag[];
}
export interface TagCreateInput {
name: string;
tag_type: TagType;
color?: string;
description?: string;
}
export interface TagUpdateInput {
name?: string;
color?: string;
description?: string;
}
+5 -3
View File
@@ -14,9 +14,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
model_name: string | undefined;
thinking_enabled: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
agent_id?: string;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -93,7 +93,7 @@ export function useExpertAuthBootstrap() {
useEffect(() => {
void bootstrap().catch((error) => {
console.error('专家中心初始化失败:', error);
console.error('智能体中心初始化失败:', error);
});
}, [bootstrap]);
+63 -4
View File
@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
@@ -41,6 +42,9 @@ export default function ChatPage() {
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
useEffect(() => {
@@ -50,6 +54,7 @@ export default function ChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
}
}, [isNewThread, threadId]);
@@ -87,15 +92,57 @@ export default function ChatPage() {
},
});
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
// Only the opening turn of a brand-new thread may carry the prefix.
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
// The prefix is intentionally NOT concatenated into the message text:
// doing so would make the user see it. We only flag the request via
// context; the backend tags the message and PromptPrefixMiddleware
// injects the prefix into the LLM request only. Writing mode adds the
// active markdown artifact path so the agent keeps editing the canvas.
const extraContext: Record<string, unknown> = {};
if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true;
}
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
void sendMessage(
threadId,
message,
Object.keys(extraContext).length > 0 ? extraContext : undefined,
);
},
[sendMessage, threadId],
[
sendMessage,
threadId,
isNewThread,
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
settings.context.writing_mode,
writingArtifactPath,
],
);
const handleClarificationSubmit = useCallback(
@@ -109,6 +156,17 @@ export default function ChatPage() {
await thread.stop();
}, [thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread.
if (!isNewThread || hasStartedConversation) return undefined;
return {
label: promptPrefixSettings.switch_label || "前缀",
on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v),
};
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
@@ -195,6 +253,7 @@ export default function ChatPage() {
onContextChange={(context) =>
setSettings("context", context)
}
promptPrefix={promptPrefixProp}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
+458
View File
@@ -0,0 +1,458 @@
"use client";
import { Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
downloadLlmMetricsExcel,
type LlmMetricsFilters,
} from "@/core/llm-metrics/api";
import { useLlmMetrics } from "@/core/llm-metrics/hooks";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 50;
type StatusFilter = "all" | "success" | "error";
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
// Prefer the resolved email's local part (sent by the backend). Fall back to
// the raw UUID only when no account record exists for the recorded user_id —
// showing a UUID is uglier but preserves the link to who triggered the call.
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
const id = (row.user_id ?? "").trim();
return id || "—";
}
function formatDate(value?: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function formatNumber(value?: number | null, fractionDigits = 0): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
return value.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
function formatDuration(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
if (value === 0) return "0 ms";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const minutes = Math.floor(value / 60_000);
const seconds = Math.round((value % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
function speedColor(tps?: number | null): string {
if (tps == null) return "#9ca3af";
if (tps >= 50) return "#16a34a";
if (tps >= 30) return "#65a30d";
if (tps >= 15) return "#eab308";
if (tps >= 5) return "#f97316";
return "#ef4444";
}
export default function LlmMetricsPage() {
const [page, setPage] = useState(0);
const [model, setModel] = useState("");
const [user, setUser] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
const filters = useMemo<LlmMetricsFilters>(() => {
const out: LlmMetricsFilters = {};
if (model.trim()) out.model_name = model.trim();
if (user.trim()) out.user_name = user.trim();
if (status !== "all") out.status = status;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [model, user, status, since, until]);
const query = useMemo(
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
[filters, page],
);
const { data, isLoading, error, isFetching } = useLlmMetrics(query);
const handleExport = async () => {
setExporting(true);
try {
await downloadLlmMetricsExcel(filters);
toast.success("已导出为 Excel");
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败");
} finally {
setExporting(false);
}
};
const resetFilters = () => {
setModel("");
setUser("");
setStatus("all");
setSince("");
setUntil("");
setPage(0);
};
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Hide columns that are empty for every visible row. Keeps the table dense
// when older records lack a tokens breakdown — better to drop the column
// than fill the page with placeholder dashes.
const visibleColumns = useMemo(() => {
type Col = {
id: string;
header: string;
align?: "left" | "right";
alwaysShow?: boolean;
hasValue: (row: typeof items[number]) => boolean;
render: (row: typeof items[number]) => React.ReactNode;
title?: (row: typeof items[number]) => string | undefined;
className?: string;
};
const columns: Col[] = [
{
id: "time",
header: "时间",
alwaysShow: true,
hasValue: () => true,
render: (row) => formatDate(row.created_at),
},
{
id: "user",
header: "用户",
alwaysShow: true,
hasValue: () => true,
render: (row) => displayUser(row),
title: (row) => row.user_id ?? undefined,
},
{
id: "model",
header: "模型",
alwaysShow: true,
hasValue: () => true,
render: (row) => row.model_name ?? "—",
},
{
id: "agent",
header: "Agent",
hasValue: (row) => Boolean(row.agent_name),
render: (row) => row.agent_name ?? "—",
},
{
id: "input",
header: "输入",
align: "right",
hasValue: (row) => row.input_tokens != null && row.input_tokens > 0,
render: (row) => formatNumber(row.input_tokens),
className: "tabular-nums",
},
{
id: "output",
header: "输出",
align: "right",
hasValue: (row) => row.output_tokens != null && row.output_tokens > 0,
render: (row) => formatNumber(row.output_tokens),
className: "tabular-nums",
},
{
id: "total",
header: "总 Token",
align: "right",
hasValue: (row) => row.total_tokens != null && row.total_tokens > 0,
render: (row) => formatNumber(row.total_tokens),
className: "tabular-nums",
},
{
id: "duration",
header: "耗时",
align: "right",
alwaysShow: true,
hasValue: (row) => row.duration_ms != null && row.duration_ms > 0,
render: (row) => formatDuration(row.duration_ms),
title: (row) => `${row.duration_ms} ms`,
className: "tabular-nums",
},
{
id: "tokens_per_sec",
header: "tok/s",
align: "right",
hasValue: (row) => row.tokens_per_sec != null && row.tokens_per_sec > 0,
render: (row) =>
row.tokens_per_sec != null ? (
<span
className="font-mono tabular-nums"
style={{ color: speedColor(row.tokens_per_sec) }}
>
{row.tokens_per_sec.toFixed(1)}
</span>
) : (
"—"
),
},
{
id: "status",
header: "状态",
alwaysShow: true,
hasValue: () => true,
render: (row) => (
<Badge
variant={row.status === "success" ? "default" : "destructive"}
>
{row.status === "success" ? "成功" : "失败"}
</Badge>
),
},
{
id: "error",
header: "错误",
hasValue: (row) => Boolean(row.error_type || row.error_message),
render: (row) => row.error_type ?? row.error_message ?? "—",
title: (row) => row.error_message ?? undefined,
className: "text-muted-foreground max-w-[260px] truncate",
},
];
return columns.filter(
(col) => col.alwaysShow || items.some((row) => col.hasValue(row)),
);
}, [items]);
const colSpan = visibleColumns.length || 1;
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">大模型调用统计</h1>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
后台无条件记录,与前端速度指示器开关无关。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
{exporting ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Download className="mr-1 size-4" />
)}
导出 Excel
</Button>
</div>
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">模型</label>
<Input
value={model}
placeholder="按模型名筛选"
onChange={(e) => {
setModel(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">用户名</label>
<Input
value={user}
placeholder="按用户名匹配(@ 前部分)"
onChange={(e) => {
setUser(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">状态</label>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as StatusFilter);
setPage(0);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">开始时间</label>
<Input
type="datetime-local"
value={since}
onChange={(e) => {
setSince(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">结束时间</label>
<Input
type="datetime-local"
value={until}
onChange={(e) => {
setUntil(e.target.value);
setPage(0);
}}
/>
</div>
<div className="md:col-span-5 flex justify-end">
<Button variant="outline" size="sm" onClick={resetFilters}>
清空筛选
</Button>
</div>
</div>
<div className="rounded-xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<tr>
{visibleColumns.map((col) => (
<Th
key={col.id}
className={col.align === "right" ? "text-right" : undefined}
>
{col.header}
</Th>
))}
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={colSpan} className="py-10 text-center">
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
</td>
</tr>
)}
{!isLoading && error && (
<tr>
<td colSpan={colSpan} className="text-destructive py-10 text-center text-sm">
加载失败:{error.message}
</td>
</tr>
)}
{!isLoading && !error && items.length === 0 && (
<tr>
<td colSpan={colSpan} className="text-muted-foreground py-10 text-center text-sm">
暂无符合条件的调用记录。
</td>
</tr>
)}
{items.map((row) => (
<tr key={row.id} className="border-t">
{visibleColumns.map((col) => (
<Td
key={col.id}
className={cn(
col.align === "right" && "text-right",
col.className,
)}
title={col.title?.(row)}
>
{col.render(row)}
</Td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
<span className="text-muted-foreground">
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 0 || isLoading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
上一页
</Button>
<span className="text-muted-foreground">
第 {page + 1} / {totalPages} 页
</span>
<Button
size="sm"
variant="outline"
disabled={page + 1 >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
</div>
);
}
function Th({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th
className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}
>
{children}
</th>
);
}
function Td({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) {
return (
<td
className={`px-3 py-2 align-top text-sm ${className ?? ""}`}
title={title}
>
{children}
</td>
);
}
+1 -1
View File
@@ -54,7 +54,7 @@ export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
<h1 className="text-xl font-semibold">正在进入智能体模式</h1>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
+12 -12
View File
@@ -153,7 +153,7 @@ export default function NewAgentPage() {
// the user wants the name box to stay blank while they chat, and we
// refill it later (with the model's proposed name) on setup_agent
// approval.
name = `未命名专家-${Date.now().toString(36)}`;
name = `未命名智能体-${Date.now().toString(36)}`;
}
const modelOverride = modelInput.trim() || null;
@@ -226,7 +226,7 @@ export default function NewAgentPage() {
if (!saved) {
return;
}
toast.success(`专家「${saved.name}」已保存`);
toast.success(`智能体「${saved.name}」已保存`);
}, [ensureAgent]);
const handleChatSubmit = useCallback(
@@ -270,7 +270,7 @@ export default function NewAgentPage() {
const handleRejectSetupAgent = useCallback(
async () => {
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
await handleChatSubmit("我暂时不批准创建这个智能体配置,请继续帮我调整。");
},
[handleChatSubmit],
);
@@ -300,7 +300,7 @@ export default function NewAgentPage() {
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">创建专家</h1>
<h1 className="text-sm font-semibold">创建智能体</h1>
</div>
{agent ? (
<Button
@@ -353,7 +353,7 @@ export default function NewAgentPage() {
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
placeholder="直接告诉模型你想要什么样的智能体,或回答模型的问题"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
@@ -366,16 +366,16 @@ export default function NewAgentPage() {
<aside className="bg-background min-h-0 overflow-y-auto">
<div className="space-y-5 p-4">
<div>
<h2 className="text-sm font-semibold">专家配置</h2>
<h2 className="text-sm font-semibold">智能体配置</h2>
<p className="text-muted-foreground mt-1 text-xs">
这些内容会作为创建专家时的初始配置。
这些内容会作为创建智能体时的初始配置。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
placeholder="例如:合同审查专家"
placeholder="例如:合同审查智能体"
value={nameInput}
onChange={(event) => {
setNameInput(event.currentTarget.value);
@@ -388,7 +388,7 @@ export default function NewAgentPage() {
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
placeholder="简要说明这个专家擅长什么"
placeholder="简要说明这个智能体擅长什么"
value={descriptionInput}
onChange={(event) =>
setDescriptionInput(event.currentTarget.value)
@@ -415,14 +415,14 @@ export default function NewAgentPage() {
))}
</select>
<p className="text-muted-foreground text-xs">
该专家被使用时调用的模型。
该智能体被使用时调用的模型。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
placeholder="写下专家的人设、边界、工作方式和输出要求"
placeholder="写下智能体的人设、边界、工作方式和输出要求"
value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
@@ -479,7 +479,7 @@ export default function NewAgentPage() {
disabled={busy || isSkillsLoading}
>
<SaveIcon className="h-4 w-4" />
{agentId ? "保存专家" : "创建专家"}
{agentId ? "保存智能体" : "创建智能体"}
</Button>
</div>
</aside>
@@ -0,0 +1,125 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
usePromptPrefixSettings,
useUpdatePromptPrefixSettings,
} from "@/core/system-settings/hooks";
export default function PromptPrefixSettingsPage() {
const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings();
const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState("");
// Only seed the draft from server data once. Re-syncing every time react-query
// refetches (window focus, route remount, etc.) would silently overwrite the
// user's in-progress edits — and made the toggle look unresponsive: a click
// would flip state to true and the next refetch would reset it to false.
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setSwitchLabel(data.switch_label);
setPromptPrefix(data.prompt_prefix);
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
if (enabled && !switchLabel.trim()) {
toast.error("开启时必须填写开关名称");
return;
}
if (enabled && !promptPrefix.trim()) {
toast.error("开启时必须填写拼接的前缀内容");
return;
}
try {
await update.mutateAsync({
enabled,
switch_label: switchLabel,
prompt_prefix: promptPrefix,
});
toast.success("已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">问答前缀配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">向用户暴露开关</div>
<div className="text-muted-foreground mt-0.5 text-xs">
关闭时,问答页不显示该开关,且后端不会做任何拼接。
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">开关名称</p>
<p className="text-muted-foreground text-xs">
展示在问答页输入框附近的标签文本。
</p>
<Input
value={switchLabel}
onChange={(e) => setSwitchLabel(e.target.value)}
placeholder="例:加入立场前缀"
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">拼接内容</p>
<p className="text-muted-foreground text-xs">
拼接到首条提问最前面的文字。例如:你需要站在中方角度,进行评价。
</p>
<Textarea
rows={4}
value={promptPrefix}
onChange={(e) => setPromptPrefix(e.target.value)}
placeholder="在新会话的第一次提问前自动加入这段文字"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
import { useParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -23,9 +24,11 @@ import {
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
updateScheduledTaskRunFile,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
@@ -92,6 +95,8 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
type EditState = { draft: string; saving: boolean };
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
@@ -101,6 +106,9 @@ export default function ScheduledTaskRunDetailPage() {
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
const canEditRun = run?.can_edit === true;
const canDeleteRun = canEditRun;
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
@@ -170,8 +178,82 @@ export default function ScheduledTaskRunDetailPage() {
}
};
const handleDownloadFileAsWord = async (file: ScheduledTaskResultFileContent) => {
// Prefer the in-progress draft if the user is editing the file.
const source = edits[file.index]?.draft ?? file.content ?? "";
if (!source) {
toast.error("文件内容未加载,无法转换为 Word");
return;
}
try {
await downloadMarkdownAsDocx(source, file.name, result?.title);
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
}
};
const handleEnterEdit = useCallback((file: ScheduledTaskResultFileContent) => {
setEdits((prev) => ({
...prev,
[file.index]: { draft: file.content ?? "", saving: false },
}));
}, []);
const handleCancelEdit = useCallback((index: number) => {
setEdits((prev) => ({ ...prev, [index]: null }));
}, []);
const handleSaveEdit = useCallback(
async (file: ScheduledTaskResultFileContent) => {
if (!run) return;
const current = edits[file.index];
if (!current) return;
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: true },
}));
try {
const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft);
setFileContents((prev) =>
prev.map((entry) =>
entry.index === file.index
? { ...entry, content: current.draft, size: updated.size ?? entry.size }
: entry,
),
);
setEdits((prev) => ({ ...prev, [file.index]: null }));
toast.success(`文件 ${file.name} 已保存`);
} catch (error) {
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: false },
}));
toast.error(error instanceof Error ? error.message : "保存失败");
}
},
[edits, run],
);
const handleDownloadBodyAsWord = async () => {
if (!result?.content) {
toast.error("正文为空,无法转换为 Word");
return;
}
try {
await downloadMarkdownAsDocx(
result.content,
result.title || "scheduled-task-result",
result.title || undefined,
);
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
if (!run || !canDeleteRun) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
@@ -207,10 +289,23 @@ export default function ScheduledTaskRunDetailPage() {
<Download className="size-3" />
下载全部
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
<Trash2 className="size-3" />
删除
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleDownloadBodyAsWord}
disabled={!result?.content}
title="将正文 Markdown 导出为 .docx"
>
<FileText className="size-3" />
下载正文 Word
</Button>
{canDeleteRun && (
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun}>
<Trash2 className="size-3" />
删除
</Button>
)}
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
@@ -281,26 +376,94 @@ export default function ScheduledTaskRunDetailPage() {
</div>
)}
<div className="flex flex-col gap-3">
{fileContents.map((file) => (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
{fileContents.map((file) => {
const isMarkdown = isMarkdownFile(file);
const editable = canEditRun && file.previewable;
const editState = edits[file.index] ?? null;
const isEditing = editState !== null;
return (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
</div>
</div>
<div className="flex flex-wrap gap-2">
{isMarkdown && (
<Button
size="sm"
variant="outline"
onClick={() => handleDownloadFileAsWord(file)}
disabled={!file.content && !editState?.draft}
title="将 Markdown 导出为 Word"
>
<FileText className="size-4" />
下载 Word
</Button>
)}
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
{editable && !isEditing && (
<Button size="sm" variant="outline" onClick={() => handleEnterEdit(file)}>
<Edit3 className="size-4" />
编辑
</Button>
)}
{isEditing && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleCancelEdit(file.index)}
disabled={editState?.saving}
>
<X className="size-4" />
取消
</Button>
<Button
size="sm"
onClick={() => handleSaveEdit(file)}
disabled={editState?.saving}
>
{editState?.saving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
保存
</Button>
</>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
<div className="p-3">
{isEditing ? (
<Textarea
className="min-h-80 font-mono text-xs"
value={editState?.draft ?? ""}
disabled={editState?.saving}
onChange={(event) =>
setEdits((prev) => ({
...prev,
[file.index]: {
draft: event.target.value,
saving: prev[file.index]?.saving ?? false,
},
}))
}
/>
) : (
<RenderPreview file={file} />
)}
</div>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
);
})}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
+153 -25
View File
@@ -15,6 +15,7 @@ import {
Play,
Plus,
RefreshCw,
TagsIcon,
Trash2,
} from "lucide-react";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
@@ -35,7 +36,11 @@ import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
@@ -73,39 +78,91 @@ const MINUTES = [0, 15, 30, 45];
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
function buildCron(cfg: ScheduleConfig): string {
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
switch (cfg.freq) {
case "hourly":
return `${cfg.minute} * * * *`;
return `${m} * * * *`;
case "daily":
return `0 ${cfg.hour} * * *`;
return `${m} ${cfg.hour} * * *`;
case "weekly":
return `0 ${cfg.hour} * * ${cfg.weekday}`;
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
case "monthly":
return `0 ${cfg.hour} ${cfg.date} * *`;
return `${m} ${cfg.hour} ${cfg.date} * *`;
}
}
function parseCron(cron: string): ScheduleConfig | null {
// Parse a 5-field cron expression into the form picker state.
//
// Bug we keep getting bitten by: the edit dialog used to call
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
// input (free-text like "每天 9 点") rather than a real cron string. The
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
// reinterpret the task as "every day at 09:00" — or, when natural-language
// resolved to "0 * * * *" on the backend, the picker honestly reflected
// that as "每小时 0 分". Either way the user lost their original intent.
//
// Caller now prefers `task.cron_expr` (the canonical schedule produced by
// `_schedule_to_cron`); this function stays the single source of truth for
// cron → picker, and also handles the looser step-syntax variants
// (e.g. "0 [STAR]/3 * * *") we sometimes generate.
function parseCron(cron: string | null | undefined): ScheduleConfig | null {
if (!cron) return null;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [min, hr, date, , wd] = parts;
const minute = parseInt(min ?? "", 10);
const hour = parseInt(hr ?? "", 10);
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
// cron isn't misread as daily.
if (hr === "*" && date === "*" && wd === "*") {
const minute = parseInt(min);
if (!isNaN(minute)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
// picker doesn't model "every N hours" yet, so this is the safest fallback
// that at least doesn't silently round to 9:00.
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
if (date === "*" && wd === "*") {
const hour = parseInt(hr);
if (!isNaN(hour)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "daily", hour };
if (!isNaN(hour)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "daily",
hour,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (date === "*" && wd !== "*") {
const hour = parseInt(hr);
const weekday = parseInt(wd);
if (!isNaN(hour) && !isNaN(weekday)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "weekly", hour, weekday };
const weekday = parseInt(wd ?? "", 10);
if (!isNaN(hour) && !isNaN(weekday)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "weekly",
hour,
weekday,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (wd === "*" && date !== "*") {
const hour = parseInt(hr);
const d = parseInt(date);
if (!isNaN(hour) && !isNaN(d)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "monthly", hour, date: d };
const d = parseInt(date ?? "", 10);
if (!isNaN(hour) && !isNaN(d)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "monthly",
hour,
date: d,
minute: isNaN(minute) ? 0 : minute,
};
}
}
return null;
}
@@ -191,6 +248,14 @@ function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
/** Render a creator-like identifier without the email domain part. */
function displayUsername(value?: string | null): string {
const raw = (value ?? "").trim();
if (!raw) return "";
const at = raw.indexOf("@");
return at === -1 ? raw : raw.slice(0, at);
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
@@ -223,7 +288,10 @@ const emptyForm = {
export default function ScheduledTasksPage() {
const navigate = useNavigate();
const { data: tasks = [], isLoading } = useScheduledTasks();
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -239,6 +307,8 @@ export default function ScheduledTasksPage() {
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const sortedTasks = useMemo(
() =>
@@ -248,6 +318,7 @@ export default function ScheduledTasksPage() {
[tasks],
);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
const filteredRuns = useMemo(() => {
@@ -292,7 +363,14 @@ export default function ScheduledTasksPage() {
prompt: task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
});
setScheduleConfig(parseCron(task.schedule_text) ?? DEFAULT_SCHEDULE_CONFIG);
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
// which is what made the dialog default back to "每天9点 → 每小时0分".
setScheduleConfig(
parseCron(task.cron_expr) ??
parseCron(task.schedule_text) ??
DEFAULT_SCHEDULE_CONFIG,
);
};
const closeDialog = () => {
@@ -398,6 +476,14 @@ export default function ScheduledTasksPage() {
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === "mine" && (
<TagSearchBar
targetType="scheduled_task"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
/>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -451,8 +537,31 @@ export default function ScheduledTasksPage() {
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
{(task.creator_email || task.creator_user_id) && (
<div className="truncate">
创建者:
{displayUsername(task.creator_email) ||
task.creator_user_id}
</div>
)}
</div>
{(task.tags?.length ?? 0) > 0 && (
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
{isAdmin && (
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
)}
{isOwner ? (
<>
<Button
@@ -641,6 +750,13 @@ export default function ScheduledTasksPage() {
<p className="text-muted-foreground text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs">
创建者:
{displayUsername(selectedTask.creator_email) ||
selectedTask.creator_user_id}
</p>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -700,14 +816,16 @@ export default function ScheduledTasksPage() {
<Download className="size-4" />
下载结果
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
{selectedTaskCanEditRuns && run.can_edit !== false && (
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
)}
</div>
</div>
))}
@@ -850,6 +968,16 @@ export default function ScheduledTasksPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
targetType="scheduled_task"
targetId={tagPickerTask.task_id}
currentTags={tagPickerTask.tags ?? []}
/>
)}
</WorkspaceContainer>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { TagManagementPage } from "@/components/workspace/admin/tag-management-page";
export default function TagsPage() {
return <TagManagementPage />;
}
@@ -20,9 +20,12 @@ import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -181,6 +184,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
@@ -193,6 +208,30 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</RouteBaseProvider>
);