chore: update ji local changes

- frontend: scheduled tasks pages, system-settings api, workspace nav menu
- backend: system_settings router, app_config, log_system_config, config.yaml

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-04 09:18:32 +08:00
1 parent aeef1dca59
commit 925675ab54
11 files changed
+172 -34

No files matched your search

@@ -10,6 +10,7 @@ import {
HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldCheckIcon,
@@ -36,6 +37,10 @@ import {
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
loadLogSystemSettings,
type LogSystemSettings,
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
@@ -71,6 +76,7 @@ export function WorkspaceNavMenu() {
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
@@ -79,6 +85,21 @@ export function WorkspaceNavMenu() {
setMounted(true);
}, []);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
loadLogSystemSettings()
.then((settings) => {
if (!cancelled) setLogSystem(settings);
})
.catch(() => {
if (!cancelled) setLogSystem(null);
});
return () => {
cancelled = true;
};
}, [isAdmin]);
return (
<>
<SettingsDialog
@@ -208,6 +229,20 @@ export function WorkspaceNavMenu() {
{t.adminTags.title}
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
window.open(
logSystem.url,
"_blank",
"noopener,noreferrer",
)
}
>
<ScrollTextIcon />
{logSystem.label || "日志系统"}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
@@ -147,6 +147,22 @@ export async function updateCitationDisplaySettings(
return (await response.json()) as CitationDisplaySettings;
}
export interface LogSystemSettings {
enabled: boolean;
url: string;
label: string;
}
export async function loadLogSystemSettings(): Promise<LogSystemSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/log-system`,
);
if (!response.ok) {
throw new Error(`Failed to load log system settings: ${response.status}`);
}
return (await response.json()) as LogSystemSettings;
}
export async function updatePrivateSquareSettings(
body: PrivateSquareUpdate,
): Promise<PrivateSquareStatus> {
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Download,
Edit3,
@@ -71,6 +72,7 @@ const emptyForm = {
};
export default function AdminScheduledTasksPage() {
const queryClient = useQueryClient();
const { theme } = useTheme();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -178,8 +180,15 @@ export default function AdminScheduledTasksPage() {
};
const triggerTask = async (task: ScheduledTask) => {
setSelectedTaskId(task.task_id);
try {
await actions.trigger.mutateAsync(task.task_id);
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "admin", task.task_id, "runs"],
});
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "admin", "list"],
});
toast.success("已触发执行");
} catch (error) {
toast.error(error instanceof Error ? error.message : "触发失败");
@@ -208,23 +217,24 @@ export default function AdminScheduledTasksPage() {
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)] xl:grid-rows-1">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-3">
<div>
<div className="min-w-0">
<CardTitle>定时任务管理</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
管理员视图:查看、编辑、删除全部用户的定时任务
</p>
</div>
<form
className="flex gap-2"
className="flex min-w-0 gap-2"
onSubmit={(event) => {
event.preventDefault();
setSearch(searchInput.trim());
}}
>
<Input
className="min-w-0"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="按任务名称搜索"
@@ -268,7 +278,7 @@ export default function AdminScheduledTasksPage() {
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{task.name}
</div>
@@ -348,10 +358,10 @@ export default function AdminScheduledTasksPage() {
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-2">
<CardTitle>执行记录</CardTitle>
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground min-w-0 truncate text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
</p>
</CardHeader>
@@ -379,11 +389,11 @@ export default function AdminScheduledTasksPage() {
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{run.result?.title || formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
<div className="text-muted-foreground mt-1 break-words text-xs">
计划:{formatDate(run.scheduled_for)} · 开始:
{formatDate(run.started_at)}
</div>
@@ -97,7 +97,7 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>;
}
/**
@@ -162,9 +162,9 @@ function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
const isAi = message.event_type === "llm.ai.response";
return (
<div className="rounded-md border">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
<div className="min-w-0 rounded-md border">
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-3 py-1.5">
<span className={cn("max-w-full break-all rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
{role.label}
{toolName ? ` · ${toolName}` : ""}
</span>
@@ -428,9 +428,9 @@ export default function ScheduledTaskRunDetailPage() {
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
<Card className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
@@ -470,15 +470,15 @@ export default function ScheduledTaskRunDetailPage() {
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div className="text-muted-foreground grid min-w-0 gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div className="min-w-0 break-all">ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
<div className="text-destructive mt-2 break-words rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
@@ -502,8 +502,8 @@ export default function ScheduledTaskRunDetailPage() {
}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<div className="min-w-0 overflow-x-auto border-b px-3 pt-1">
<TabsList className="h-8 w-max">
{result && isHtmlRun && (
<TabsTrigger value="html" className="text-xs">
<Code2 className="mr-1 size-3" />
@@ -573,7 +573,7 @@ export default function ScheduledTaskRunDetailPage() {
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
<pre className="whitespace-pre-wrap break-words text-sm">
{result.content || "无正文内容"}
</pre>
)}
@@ -594,9 +594,9 @@ export default function ScheduledTaskRunDetailPage() {
const editState = edits[file.index] ?? null;
const isEditing = editState !== null;
return (
<div key={file.index} className="rounded-md border">
<div key={file.index} className="min-w-0 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="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
@@ -679,7 +679,7 @@ export default function ScheduledTaskRunDetailPage() {
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
<pre className="bg-muted overflow-auto whitespace-pre-wrap break-words rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
+31 -10
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BellOff,
@@ -133,6 +134,7 @@ const DESCRIPTION_TEXTAREA_ROWS = 4;
const PROMPT_TEXTAREA_ROWS = 10;
export default function ScheduledTasksPage() {
const queryClient = useQueryClient();
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
@@ -361,15 +363,35 @@ export default function ScheduledTasksPage() {
}
};
const handleTriggerTask = async (task: ScheduledTask) => {
setSelectedTaskId(task.task_id);
setRunsTimeFilter("all");
try {
await actions.trigger.mutateAsync(task.task_id);
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", task.task_id, "runs"],
});
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "list"],
});
toast.success("已触发执行", darkToastOpts.success);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "触发失败",
darkToastOpts.error,
);
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)] xl:grid-rows-1">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="min-w-0">
<CardTitle>定时任务</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
选择任务后,在右侧查看全部执行记录
@@ -563,8 +585,7 @@ export default function ScheduledTasksPage() {
title="立即执行"
onClick={(event) => {
event.stopPropagation();
actions.trigger.mutate(task.task_id);
toast.success("已触发执行", darkToastOpts.success);
void handleTriggerTask(task);
}}
>
<RefreshCw className="size-4" />
@@ -715,9 +736,9 @@ export default function ScheduledTasksPage() {
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-2">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<CardTitle>执行记录</CardTitle>
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
<SelectTrigger className="h-7 w-28 text-xs">
@@ -731,7 +752,7 @@ export default function ScheduledTasksPage() {
</SelectContent>
</Select>
</div>
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground min-w-0 truncate text-sm">
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
@@ -770,11 +791,11 @@ export default function ScheduledTasksPage() {
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{run.result?.title ? desensitize(run.result.title) : formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
<div className="text-muted-foreground mt-1 break-words text-xs">
计划:{formatDate(run.scheduled_for)} · 开始:
{formatDate(run.started_at)}
</div>
@@ -509,6 +509,8 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`.
- `log_system` - Admin-only external log-system entry (`deerflow/config/log_system_config.py`): `{enabled, url, label}`. Surfaced read-only by `GET /api/system-settings/log-system` (admin only); the frontend renders it as a "日志系统" item in the bottom-right settings menu (`workspace-nav-menu.tsx`) that opens `url` in a new tab. Disabled by default.
**`extensions_config.json`**:
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)
- `skills` - Map of skill name → state (enabled)
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
PromptPrefixSettings,
SystemSettings,
@@ -200,3 +201,23 @@ async def update_appearance_settings(
else:
saved = current
return AppearanceResponse(default_theme=saved.appearance.default_theme)
class LogSystemResponse(BaseModel):
"""External log-system entry shown in the admin settings menu.
Sourced from ``config.yaml`` (``log_system`` section), so it is read-only
here — there is no PUT counterpart.
"""
enabled: bool = Field(..., description="Whether the entry is shown in the settings menu")
url: str = Field(..., description="External log-system URL opened in a new tab")
label: str = Field(..., description="Menu label shown for the entry")
@router.get("/log-system", response_model=LogSystemResponse)
async def get_log_system(request: Request) -> LogSystemResponse:
"""Return the admin-configured external log-system link from config.yaml. Admin only."""
await _require_admin(request)
cfg = get_app_config().log_system
return LogSystemResponse(enabled=cfg.enabled, url=cfg.url, label=cfg.label)
@@ -208,6 +208,13 @@ auth_login:
verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
database:
backend: sqlite
sqlite_dir: .deer-flow/data
@@ -15,6 +15,7 @@ from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
@@ -108,6 +109,7 @@ class AppConfig(BaseModel):
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)")
log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu")
sandbox: SandboxConfig = Field(description="Sandbox configuration")
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
@@ -0,0 +1,18 @@
"""Configuration for the admin-only external log-system entry.
A small read-only section in ``config.yaml`` that lets a deployment point the
"日志系统" entry (shown in the bottom-right settings menu for admins) at an
external log/observability system. The URL is opened in a new browser tab.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class LogSystemConfig(BaseModel):
"""External log-system link surfaced in the admin settings menu."""
enabled: bool = Field(default=False, description="Whether the log-system entry is shown in the settings menu")
url: str = Field(default="", description="External log-system URL opened in a new tab when the entry is clicked")
label: str = Field(default="日志系统", description="Menu label shown for the entry")
+6
View File
@@ -163,6 +163,12 @@ auth_login:
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL