From 9471e72171382120db3c5e81ce00aa9dd773da74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 5 Jun 2026 17:16:31 +0800 Subject: [PATCH 1/5] feat: add checkpoint postgres migration admin --- .../workspace/workspace-nav-menu.tsx | 17 +- .../src/pages/CheckpointMigrationPage.tsx | 257 ++++++++++++++++ frontend-web/src/pages/WorkspaceRoutes.tsx | 13 + .../backend/app/gateway/app.py | 10 +- .../gateway/routers/checkpoint_migration.py | 276 ++++++++++++++++++ offline-backend-20260512/backend/config.yaml | 10 +- .../deerflow/config/checkpointer_config.py | 26 +- .../runtime/checkpointer/async_provider.py | 102 ++++++- .../checkpointer/checkpoint_migration.py | 276 ++++++++++++++++++ offline-backend-20260512/config.yaml | 8 +- 10 files changed, 962 insertions(+), 33 deletions(-) create mode 100644 frontend-web/src/pages/CheckpointMigrationPage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/checkpoint_migration.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/checkpoint_migration.py diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 2c7c085f..eaf72024 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -4,9 +4,10 @@ import { BellIcon, BotIcon, BrainIcon, - CalendarClockIcon, - ChevronsUpDown, - GaugeIcon, + CalendarClockIcon, + ChevronsUpDown, + DatabaseZapIcon, + GaugeIcon, HistoryIcon, MessageSquareCodeIcon, MessageSquarePlusIcon, @@ -198,6 +199,16 @@ export function WorkspaceNavMenu() { 用户排行榜 )} + {isAdmin && ( + + navigate("/page/workspace/settings/checkpoint-migration") + } + > + + Checkpoint 迁移 + + )} {isAdmin && ( navigate("/page/workspace/admin")} diff --git a/frontend-web/src/pages/CheckpointMigrationPage.tsx b/frontend-web/src/pages/CheckpointMigrationPage.tsx new file mode 100644 index 00000000..29acd84e --- /dev/null +++ b/frontend-web/src/pages/CheckpointMigrationPage.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { DatabaseZap, Loader2Icon, RefreshCwIcon } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +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 { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +interface CheckpointMigrationSources { + source_paths: string[]; +} + +interface CheckpointMigrationTask { + task_id: string; + status: "queued" | "running" | "completed" | "failed"; + start_at: string; + end_at: string; + source_paths: string[]; + total_threads: number; + copied_threads: number; + skipped_threads: number; + failed_threads: number; + skipped_ranges: number; + message: string; + error?: string | null; + created_at: string; + updated_at: string; +} + +async function loadSources(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/admin/checkpoint-migration/sources`, + ); + if (!response.ok) throw new Error("加载 SQLite 来源失败"); + return (await response.json()) as CheckpointMigrationSources; +} + +async function startMigration(body: { + start_at: string; + end_at: string; +}): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const detail = await response.json().catch(() => ({ detail: "启动迁移失败" })); + throw new Error(detail.detail ?? "启动迁移失败"); + } + return (await response.json()) as CheckpointMigrationTask; +} + +async function loadTask(taskId: string): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks/${taskId}`, + ); + if (!response.ok) throw new Error("加载迁移任务失败"); + return (await response.json()) as CheckpointMigrationTask; +} + +function toLocalInputValue(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function statusVariant(status?: string) { + if (status === "failed") return "destructive" as const; + if (status === "completed") return "default" as const; + if (status === "queued" || status === "running") return "secondary" as const; + return "outline" as const; +} + +export default function CheckpointMigrationPage() { + const now = useMemo(() => new Date(), []); + const weekAgo = useMemo( + () => new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), + [now], + ); + const [startAt, setStartAt] = useState(toLocalInputValue(weekAgo)); + const [endAt, setEndAt] = useState(toLocalInputValue(now)); + const [taskId, setTaskId] = useState(null); + + const { data: sources, isLoading: sourcesLoading, refetch } = useQuery({ + queryKey: ["checkpointMigration", "sources"], + queryFn: loadSources, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + const startMutation = useMutation({ mutationFn: startMigration }); + const { data: task } = useQuery({ + queryKey: ["checkpointMigration", "task", taskId], + queryFn: () => loadTask(taskId as string), + enabled: !!taskId, + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === "queued" || status === "running" ? 2000 : false; + }, + refetchOnWindowFocus: false, + }); + + const running = + startMutation.isPending || + task?.status === "queued" || + task?.status === "running"; + + async function handleStart() { + const start = new Date(startAt); + const end = new Date(endAt); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + toast.error("请选择有效的开始和结束时间"); + return; + } + if (end <= start) { + toast.error("结束时间必须晚于开始时间"); + return; + } + try { + const created = await startMutation.mutateAsync({ + start_at: start.toISOString(), + end_at: end.toISOString(), + }); + setTaskId(created.task_id); + toast.success("迁移任务已启动"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "启动迁移失败"); + } + } + + return ( +
+
+

Checkpoint 迁移

+

+ 按时间段把 SQLite 旧聊天 checkpoint 同步到 Postgres。同一来源、同一时间段完成后再次执行会跳过。 +

+
+ +
+
+
+ + SQLite 来源 +
+ +
+ {sourcesLoading ? ( +
+ + 加载中 +
+ ) : (sources?.source_paths.length ?? 0) > 0 ? ( +
+ {sources?.source_paths.map((path) => ( +
+ {path} +
+ ))} +
+ ) : ( +
+ 未配置 SQLite 来源路径 +
+ )} +
+ +
+
+ + +
+
+ +
+
+ + {task && ( +
+
+
任务状态
+ {task.status} +
+
+
+
总线程
+
+ {task.total_threads} +
+
+
+
已复制
+
+ {task.copied_threads} +
+
+
+
已跳过
+
+ {task.skipped_threads} +
+
+
+
跳过时间段
+
+ {task.skipped_ranges} +
+
+
+ {(task.message || task.error) && ( +
+ {task.error || task.message} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index f6e68ad9..923eb137 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -25,6 +25,7 @@ import AdminSkillsPage from './AdminSkillsPage'; import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage'; import ScheduledTasksPage from './ScheduledTasksPage'; import LlmMetricsPage from './LlmMetricsPage'; +import CheckpointMigrationPage from './CheckpointMigrationPage'; import MemoryPage from './MemoryPage'; import PromptPrefixSettingsPage from './PromptPrefixSettingsPage'; import PrivateSquareSettingsPage from './PrivateSquareSettingsPage'; @@ -476,6 +477,18 @@ export default function WorkspaceRoutes({ ) } /> + + + + ) : ( + + ) + } + /> ); diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 8d6cf375..fa0ebd22 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -30,8 +30,9 @@ from app.gateway.routers import ( artifacts, assistants_compat, auth, - channels, - feedback, + channels, + checkpoint_migration, + feedback, html_page_favorites, intent, llm_metrics, @@ -905,7 +906,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an app.include_router(tool_metrics.router) # Channels API is mounted at /api/channels - app.include_router(channels.router) + app.include_router(channels.router) + + # Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres. + app.include_router(checkpoint_migration.router) # Assistants compatibility API (LangGraph Platform stub) app.include_router(assistants_compat.router) diff --git a/offline-backend-20260512/backend/app/gateway/routers/checkpoint_migration.py b/offline-backend-20260512/backend/app/gateway/routers/checkpoint_migration.py new file mode 100644 index 00000000..93202d89 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/checkpoint_migration.py @@ -0,0 +1,276 @@ +"""Admin checkpoint migration endpoints. + +These endpoints support the SQLite -> Postgres transition for LangGraph +checkpoints. The live checkpointer should be Postgres with SQLite dual-read +enabled; this router only starts manual, bounded migrations for selected time +ranges. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.auth.disabled_mode import is_auth_disabled +from app.gateway.deps import get_current_user_from_request +from deerflow.config.runtime_paths import runtime_home +from deerflow.runtime.checkpointer.checkpoint_migration import ( + SQLiteCheckpointSource, + copy_thread_state, + sqlite_recent_threads, + target_has_thread, +) +from deerflow.runtime.store._sqlite_utils import resolve_sqlite_conn_str + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/admin/checkpoint-migration", tags=["checkpoint-migration"]) + +_TASKS: dict[str, dict[str, Any]] = {} + + +class MigrationStartRequest(BaseModel): + start_at: str = Field(description="Inclusive start datetime, ISO string.") + end_at: str = Field(description="Inclusive end datetime, ISO string.") + source_paths: list[str] | None = Field(default=None, description="Optional SQLite sources. Defaults to configured dual-read paths.") + + +class MigrationTaskResponse(BaseModel): + task_id: str + status: str + start_at: str + end_at: str + source_paths: list[str] + total_threads: int = 0 + copied_threads: int = 0 + skipped_threads: int = 0 + failed_threads: int = 0 + skipped_ranges: int = 0 + message: str = "" + error: str | None = None + created_at: str + updated_at: str + + +async def _require_admin(request: Request) -> None: + if is_auth_disabled(): + return + user = await get_current_user_from_request(request) + if getattr(user, "system_role", None) != "admin": + raise HTTPException(status_code=403, detail="Checkpoint migration requires admin role") + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_dt(value: str) -> datetime: + try: + normalized = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(normalized) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _sqlite_datetime(dt: datetime) -> str: + # Existing SQLite rows store naive wall-clock strings like + # "2026-05-18 06:41:10.989427"; match that format for comparisons. + return dt.astimezone(timezone.utc).replace(tzinfo=None).isoformat(sep=" ") + + +def _configured_sources(request: Request) -> list[str]: + config = getattr(request.app.state, "config", None) + cp = getattr(config, "checkpointer", None) + paths = list(getattr(cp, "dual_read_sqlite_paths", []) or []) + raw_env = os.getenv("DEER_FLOW_CHECKPOINT_SQLITE_PATHS", "") + paths.extend(part.strip() for part in raw_env.split(",") if part.strip()) + resolved: list[str] = [] + seen: set[str] = set() + for path in paths: + conn_str = resolve_sqlite_conn_str(path) + if conn_str == ":memory:" or conn_str.startswith("file:"): + continue + if conn_str not in seen: + resolved.append(conn_str) + seen.add(conn_str) + return resolved + + +def _target_checkpointer(request: Request): + checkpointer = getattr(request.app.state, "checkpointer", None) + if checkpointer is None: + raise HTTPException(status_code=503, detail="Checkpointer is not available") + return getattr(checkpointer, "primary", checkpointer) + + +def _state_path() -> Path: + return runtime_home() / "checkpoint_migration_state.json" + + +def _load_completed_ranges() -> set[str]: + try: + payload = json.loads(_state_path().read_text(encoding="utf-8")) + except FileNotFoundError: + return set() + except Exception: + logger.warning("Ignoring unreadable checkpoint migration state file", exc_info=True) + return set() + return {str(item) for item in payload.get("completed_ranges", [])} + + +def _save_completed_ranges(items: set[str]) -> None: + path = _state_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps({"completed_ranges": sorted(items)}, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) + + +def _range_key(source: str, start_at: str, end_at: str) -> str: + raw = json.dumps({"source": str(Path(source).resolve()), "start_at": start_at, "end_at": end_at}, sort_keys=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +async def _open_sqlite_source(path: str) -> SQLiteCheckpointSource: + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + ctx = AsyncSqliteSaver.from_conn_string(path) + saver = await ctx.__aenter__() + await saver.setup() + return SQLiteCheckpointSource( + path=path, + saver=saver, + context=ctx, + progress_path=Path(path).with_suffix(Path(path).suffix + ".pg-migration.json"), + ) + + +def _task_response(task: dict[str, Any]) -> MigrationTaskResponse: + return MigrationTaskResponse(**task) + + +async def _run_task(task_id: str, target, start_at: datetime, end_at: datetime) -> None: + task = _TASKS[task_id] + completed = await asyncio.to_thread(_load_completed_ranges) + start_sql = _sqlite_datetime(start_at) + end_sql = _sqlite_datetime(end_at) + range_start = start_at.isoformat() + range_end = end_at.isoformat() + + try: + task["status"] = "running" + task["updated_at"] = _now_iso() + for source_path in task["source_paths"]: + if not Path(source_path).exists(): + task["message"] = f"SQLite source not found: {source_path}" + task["skipped_ranges"] += 1 + continue + + key = _range_key(source_path, range_start, range_end) + if key in completed: + task["skipped_ranges"] += 1 + task["message"] = "Selected range was already migrated; skipped" + task["updated_at"] = _now_iso() + continue + + thread_ids = await asyncio.to_thread(sqlite_recent_threads, source_path, since=start_sql, until=end_sql) + if not thread_ids: + task["message"] = f"No threads found in selected range for {Path(source_path).name}" + completed.add(key) + await asyncio.to_thread(_save_completed_ranges, completed) + task["updated_at"] = _now_iso() + continue + + task["total_threads"] += len(thread_ids) + source = await _open_sqlite_source(source_path) + try: + for thread_id in thread_ids: + if await target_has_thread(target, thread_id): + task["skipped_threads"] += 1 + else: + copied = await copy_thread_state(source.saver, target, thread_id) + if copied: + task["copied_threads"] += 1 + else: + task["skipped_threads"] += 1 + task["updated_at"] = _now_iso() + await asyncio.sleep(0) + finally: + with contextlib.suppress(Exception): + await source.close() + + completed.add(key) + await asyncio.to_thread(_save_completed_ranges, completed) + + task["status"] = "completed" + task["message"] = task["message"] or "Migration completed" + except Exception as exc: + logger.exception("Checkpoint migration task failed: %s", task_id) + task["status"] = "failed" + task["error"] = str(exc) + finally: + task["updated_at"] = _now_iso() + + +@router.post("/tasks", response_model=MigrationTaskResponse) +async def start_migration(body: MigrationStartRequest, request: Request): + await _require_admin(request) + start_at = _parse_dt(body.start_at) + end_at = _parse_dt(body.end_at) + if end_at <= start_at: + raise HTTPException(status_code=400, detail="end_at must be later than start_at") + + sources = [resolve_sqlite_conn_str(p) for p in (body.source_paths or _configured_sources(request))] + sources = [p for p in dict.fromkeys(sources) if p != ":memory:" and not p.startswith("file:")] + if not sources: + raise HTTPException(status_code=400, detail="No SQLite source paths configured") + + target = _target_checkpointer(request) + task_id = hashlib.sha256(f"{_now_iso()}:{sources}:{body.start_at}:{body.end_at}".encode("utf-8")).hexdigest()[:16] + now = _now_iso() + _TASKS[task_id] = { + "task_id": task_id, + "status": "queued", + "start_at": start_at.isoformat(), + "end_at": end_at.isoformat(), + "source_paths": sources, + "total_threads": 0, + "copied_threads": 0, + "skipped_threads": 0, + "failed_threads": 0, + "skipped_ranges": 0, + "message": "", + "error": None, + "created_at": now, + "updated_at": now, + } + asyncio.create_task(_run_task(task_id, target, start_at, end_at)) + return _task_response(_TASKS[task_id]) + + +@router.get("/tasks/{task_id}", response_model=MigrationTaskResponse) +async def get_migration_task(task_id: str, request: Request): + await _require_admin(request) + task = _TASKS.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Migration task not found") + return _task_response(task) + + +@router.get("/sources") +async def list_sources(request: Request): + await _require_admin(request) + return {"source_paths": _configured_sources(request)} diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index e374c4ca..1b4f0b2f 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -219,8 +219,12 @@ database: echo_sql: false checkpointer: - type: sqlite - connection_string: .deer-flow/data/deerflow.db + type: postgres + connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow + dual_read_enabled: true + dual_read_sqlite_paths: + - .deer-flow/data/deerflow.db + - ../data/.deer-flow/data/checkpoints.db # 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。 # 三层覆盖优先级(高->低): @@ -301,4 +305,4 @@ ai_writing: log_system: enabled: true url: "https://your-log-system.example.com" - label: "日志系统" \ No newline at end of file + label: "日志系统" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/checkpointer_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/checkpointer_config.py index f6164e5c..674b09f1 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/checkpointer_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/checkpointer_config.py @@ -2,7 +2,7 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field CheckpointerType = Literal["memory", "sqlite", "postgres"] @@ -16,13 +16,27 @@ class CheckpointerConfig(BaseModel): "'sqlite' persists to a local file (requires langgraph-checkpoint-sqlite). " "'postgres' persists to PostgreSQL (requires langgraph-checkpoint-postgres)." ) - connection_string: str | None = Field( - default=None, - description="Connection string for sqlite (file path) or postgres (DSN). " + connection_string: str | None = Field( + default=None, + description="Connection string for sqlite (file path) or postgres (DSN). " "Required for sqlite and postgres types. " "For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. " - "For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.", - ) + "For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.", + ) + dual_read_enabled: bool = Field( + default=False, + description=( + "When type='postgres', read from Postgres first and fall back to legacy " + "SQLite checkpoint files when Postgres does not have the requested data." + ), + ) + dual_read_sqlite_paths: list[str] = Field( + default_factory=list, + description=( + "Legacy SQLite checkpoint database paths used for read fallback. " + "Plain paths are resolved the same way as SQLite connection_string." + ), + ) # Global configuration instance — None means no checkpointer is configured. diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py index fdd4f6ac..76547771 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py @@ -17,10 +17,12 @@ For sync usage see :mod:`deerflow.runtime.checkpointer.provider`. from __future__ import annotations -import asyncio -import contextlib -import logging -from collections.abc import AsyncIterator +import asyncio +import contextlib +import logging +from collections.abc import AsyncIterator +import os +from pathlib import Path from langgraph.types import Checkpointer @@ -30,9 +32,37 @@ from deerflow.runtime.checkpointer.provider import ( POSTGRES_INSTALL, SQLITE_INSTALL, ) -from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str +from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) + + +def _csv_env(name: str) -> list[str]: + raw = os.getenv(name, "") + return [part.strip() for part in raw.split(",") if part.strip()] + + +def _dual_read_enabled(config) -> bool: + if getattr(config, "dual_read_enabled", False): + return True + return os.getenv("DEER_FLOW_CHECKPOINT_DUAL_READ", "").strip().lower() in {"1", "true", "yes", "on"} + + +def _dual_read_paths(config) -> list[str]: + paths = list(getattr(config, "dual_read_sqlite_paths", []) or []) + paths.extend(_csv_env("DEER_FLOW_CHECKPOINT_SQLITE_PATHS")) + resolved: list[str] = [] + seen: set[str] = set() + for path in paths: + conn_str = resolve_sqlite_conn_str(path) + if conn_str == ":memory:" or conn_str.startswith("file:"): + logger.warning("Skipping unsupported checkpoint migration SQLite path: %s", path) + continue + if conn_str not in seen: + resolved.append(conn_str) + seen.add(conn_str) + return resolved + # --------------------------------------------------------------------------- # Async factory @@ -61,19 +91,59 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]: yield saver return - if config.type == "postgres": - try: - from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + if config.type == "postgres": + try: + from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver except ImportError as exc: raise ImportError(POSTGRES_INSTALL) from exc - if not config.connection_string: - raise ValueError(POSTGRES_CONN_REQUIRED) - - async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver: - await saver.setup() - yield saver - return + if not config.connection_string: + raise ValueError(POSTGRES_CONN_REQUIRED) + + async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver: + await saver.setup() + if not _dual_read_enabled(config): + yield saver + return + + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + from deerflow.runtime.checkpointer.checkpoint_migration import ( + DualReadCheckpointer, + SQLiteCheckpointSource, + ) + + sources: list[SQLiteCheckpointSource] = [] + for sqlite_path in _dual_read_paths(config): + if not Path(sqlite_path).exists(): + logger.info("Checkpoint dual-read source does not exist, skipping: %s", sqlite_path) + continue + ctx = AsyncSqliteSaver.from_conn_string(sqlite_path) + source_saver = await ctx.__aenter__() + await source_saver.setup() + sources.append( + SQLiteCheckpointSource( + path=sqlite_path, + saver=source_saver, + context=ctx, + progress_path=Path(sqlite_path).with_suffix(Path(sqlite_path).suffix + ".pg-migration.json"), + ) + ) + + if not sources: + logger.info("Checkpoint dual-read enabled but no SQLite sources were opened; using Postgres only") + yield saver + return + + proxy = DualReadCheckpointer( + saver, + sources, + ) + try: + yield proxy + finally: + await proxy.aclose_sources() + return raise ValueError(f"Unknown checkpointer type: {config.type!r}") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/checkpoint_migration.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/checkpoint_migration.py new file mode 100644 index 00000000..d7302f3f --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/checkpoint_migration.py @@ -0,0 +1,276 @@ +"""SQLite-to-Postgres checkpoint migration and read fallback helpers. + +The live gateway can wrap a Postgres checkpointer with ``DualReadCheckpointer``. +New writes go to Postgres, reads prefer Postgres and fall back to legacy SQLite. +Manual migration scripts reuse the copy helpers in this module. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class SQLiteCheckpointSource: + path: str + saver: Any + context: Any + progress_path: Path + closed: bool = False + + async def close(self) -> None: + if self.closed: + return + self.closed = True + try: + await self.context.__aexit__(None, None, None) + except Exception: + logger.warning("Failed to close legacy SQLite checkpointer: %s", self.path, exc_info=True) + + +def _sqlite_db_exists(path: str) -> bool: + return Path(path).exists() + + +def _sqlite_threads(path: str) -> list[str]: + with sqlite3.connect(path) as conn: + rows = conn.execute("SELECT DISTINCT thread_id FROM checkpoints ORDER BY thread_id").fetchall() + return [str(row[0]) for row in rows if row and row[0]] + + +def _sqlite_has_table(path: str, table: str) -> bool: + with sqlite3.connect(path) as conn: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ).fetchone() + return row is not None + + +def sqlite_recent_threads(path: str, *, since: str | None = None, until: str | None = None) -> list[str]: + """Return threads updated since an ISO timestamp when ``threads_meta`` exists.""" + + if not _sqlite_has_table(path, "threads_meta"): + return [] + query = "SELECT thread_id FROM threads_meta" + where: list[str] = [] + params: list[str] = [] + if since: + where.append("updated_at >= ?") + params.append(since) + if until: + where.append("updated_at <= ?") + params.append(until) + if where: + query += " WHERE " + " AND ".join(where) + query += " ORDER BY updated_at DESC" + with sqlite3.connect(path) as conn: + rows = conn.execute(query, tuple(params)).fetchall() + return [str(row[0]) for row in rows if row and row[0]] + + +def _sqlite_thread_checkpoint_count(path: str, thread_id: str) -> int: + with sqlite3.connect(path) as conn: + row = conn.execute( + "SELECT COUNT(*) FROM checkpoints WHERE thread_id = ?", + (thread_id,), + ).fetchone() + return int(row[0] if row else 0) + + +def _load_progress(path: Path) -> set[str]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return set() + except Exception: + logger.warning("Ignoring unreadable checkpoint migration progress file: %s", path, exc_info=True) + return set() + migrated = payload.get("migrated_threads", []) + return {str(x) for x in migrated if x} + + +def _save_progress(path: Path, migrated: set[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text( + json.dumps({"migrated_threads": sorted(migrated)}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp.replace(path) + + +async def _copy_thread_state(source, target, thread_id: str) -> bool: + """Copy one thread through the public LangGraph checkpointer API.""" + + config = {"configurable": {"thread_id": thread_id}} + tuples = [t async for t in source.alist(config)] + if not tuples: + return False + + for tup in reversed(tuples): + configurable = (tup.config or {}).get("configurable", {}) or {} + checkpoint_ns = configurable.get("checkpoint_ns", "") or "" + parent_id = ((tup.parent_config or {}).get("configurable", {}) or {}).get("checkpoint_id") + + put_config: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": checkpoint_ns}} + if parent_id: + put_config["configurable"]["checkpoint_id"] = parent_id + + new_versions = (tup.checkpoint or {}).get("channel_versions", {}) or {} + await target.aput(put_config, tup.checkpoint, tup.metadata or {}, new_versions) + + if tup.pending_writes: + by_task: dict[str, list] = {} + for task_id, channel, value in tup.pending_writes: + by_task.setdefault(task_id, []).append((channel, value)) + checkpoint_id = (tup.checkpoint or {}).get("id") + for task_id, writes in by_task.items(): + writes_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + } + await target.aput_writes(writes_config, writes, task_id) + return True + + +async def copy_thread_state(source, target, thread_id: str) -> bool: + return await _copy_thread_state(source, target, thread_id) + + +async def _target_thread_checkpoint_count(target, thread_id: str) -> int: + config = {"configurable": {"thread_id": thread_id}} + count = 0 + async for _ in target.alist(config): + count += 1 + return count + + +async def target_has_thread(target, thread_id: str) -> bool: + config = {"configurable": {"thread_id": thread_id}} + async for _ in target.alist(config, limit=1): + return True + return False + + +class DualReadCheckpointer: + """Postgres-first checkpointer with SQLite read fallback.""" + + def __init__(self, primary, sources: list[SQLiteCheckpointSource], *, sleep_seconds: float = 0.05): + self.primary = primary + self.sources = sources + self.sleep_seconds = max(0.0, sleep_seconds) + self._closed = False + + def __getattr__(self, name: str): + return getattr(self.primary, name) + + async def aclose_sources(self) -> None: + self._closed = True + for source in self.sources: + await source.close() + + async def aget_tuple(self, config): + found = await self.primary.aget_tuple(config) + if found is not None: + return found + for source in self.sources: + if source.closed: + continue + found = await source.saver.aget_tuple(config) + if found is not None: + return found + return None + + async def aget(self, config): + found = await self.primary.aget(config) + if found is not None: + return found + for source in self.sources: + if source.closed: + continue + found = await source.saver.aget(config) + if found is not None: + return found + return None + + async def alist(self, config, *, filter=None, before=None, limit=None): + seen: set[tuple[str, str, str]] = set() + yielded = 0 + + async for tup in self.primary.alist(config, filter=filter, before=before, limit=limit): + key = self._tuple_key(tup) + seen.add(key) + yield tup + yielded += 1 + if limit is not None and yielded >= limit: + return + + remaining = None if limit is None else max(0, limit - yielded) + for source in self.sources: + if source.closed: + continue + async for tup in source.saver.alist(config, filter=filter, before=before, limit=remaining): + key = self._tuple_key(tup) + if key in seen: + continue + seen.add(key) + yield tup + yielded += 1 + if limit is not None: + remaining = max(0, limit - yielded) + if remaining <= 0: + return + + async def aput(self, *args, **kwargs): + return await self.primary.aput(*args, **kwargs) + + async def aput_writes(self, *args, **kwargs): + return await self.primary.aput_writes(*args, **kwargs) + + async def adelete_thread(self, thread_id: str) -> None: + delete = getattr(self.primary, "adelete_thread", None) + if delete is not None: + await delete(thread_id) + for source in self.sources: + if source.closed: + continue + delete = getattr(source.saver, "adelete_thread", None) + if delete is not None: + await delete(thread_id) + + @staticmethod + def _tuple_key(tup) -> tuple[str, str, str]: + configurable = (tup.config or {}).get("configurable", {}) or {} + return ( + str(configurable.get("thread_id", "")), + str(configurable.get("checkpoint_ns", "") or ""), + str(configurable.get("checkpoint_id", "") or (tup.checkpoint or {}).get("id", "")), + ) + + async def _verify_source(self, source: SQLiteCheckpointSource, threads: list[str]) -> bool: + for thread_id in threads: + source_count = await asyncio.to_thread(_sqlite_thread_checkpoint_count, source.path, thread_id) + target_count = await _target_thread_checkpoint_count(self.primary, thread_id) + if target_count < source_count: + logger.warning( + "Checkpoint migration verification failed: source=%s thread_id=%s source_count=%d target_count=%d", + source.path, + thread_id, + source_count, + target_count, + ) + return False + return True diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 126f7489..a48620c2 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -187,8 +187,12 @@ checkpointer: # Docker 部署:把容器内 .deer-flow 目录挂卷出来即可持久化(撑住容器重启)。 # 这里同时被「主 agent」和「AI 写作 graph」共用 —— LangGraph 用 # (thread_id, checkpoint_ns) 做隔离,session_id 是 uuid,不会撞。 - type: sqlite - connection_string: .deer-flow/data/checkpoints.db + type: postgres + connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow + dual_read_enabled: true + dual_read_sqlite_paths: + - backend/.deer-flow/data/deerflow.db + - data/.deer-flow/data/checkpoints.db # AI 写作子系统的会话清理配置。 # 业务表(ai_writing_sessions @ MySQL) + checkpointer(checkpoints.db @ SQLite) 联动清理。 # 留意:清理走 cron 表达式调度,默认每天凌晨 3 点跑一次,按 updated_at 一刷拉清。 From 690d09a3d1e347903f63d181de860cd119ec4cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 5 Jun 2026 17:21:49 +0800 Subject: [PATCH 2/5] feat: add workspace view mode settings --- .../workspace/agents/agent-card.tsx | 626 ++++++++++-------- .../workspace/agents/agent-gallery.tsx | 26 +- .../settings/appearance-settings-page.tsx | 71 +- .../settings/skill-settings-page.tsx | 485 ++++++++------ .../components/workspace/view-mode-toggle.tsx | 56 ++ frontend-web/src/core/settings/local.ts | 7 + frontend-web/src/core/system-settings/api.ts | 5 + .../src/core/system-settings/hooks.ts | 28 + frontend-web/src/pages/ScheduledTasksPage.tsx | 19 +- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/auth_middleware.py | 1 + .../app/gateway/routers/system_settings.py | 24 +- .../deerflow/config/system_settings.py | 8 + 13 files changed, 842 insertions(+), 518 deletions(-) create mode 100644 frontend-web/src/components/workspace/view-mode-toggle.tsx diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index cb4e510c..c60e2084 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -6,9 +6,9 @@ import { GlobeIcon, InfoIcon, LockIcon, - MessageSquareIcon, - PinIcon, - ShieldIcon, + MessageSquareIcon, + PinIcon, + ShieldIcon, SparklesIcon, SquarePenIcon, StarIcon, @@ -54,11 +54,11 @@ import { useAgent, useCreateAgent, useDeleteAgent, - useSetAgentFeatured, - useSetAgentPrivate, - useToggleAgentFavorite, - useToggleAgentPin, - useUpdateAgent, + useSetAgentFeatured, + useSetAgentPrivate, + useToggleAgentFavorite, + useToggleAgentPin, + useUpdateAgent, } from "@/core/agents"; import { getAgent } from "@/core/agents/api"; import type { Agent } from "@/core/agents"; @@ -70,6 +70,9 @@ import { cn } from "@/lib/utils"; interface AgentCardProps { agent: Agent; + /** "card" (default) renders the full gallery card; "list" renders a compact + * single-row layout for the list view. All dialogs/actions are shared. */ + layout?: "card" | "list"; } interface BadgeItem { @@ -181,16 +184,17 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) { ); } -export function AgentCard({ agent }: AgentCardProps) { +export function AgentCard({ agent, layout = "card" }: AgentCardProps) { + const isList = layout === "list"; const { t } = useI18n(); const router = useRouter(); const deleteAgent = useDeleteAgent(); const updateAgent = useUpdateAgent(); const createAgent = useCreateAgent(); const setFeatured = useSetAgentFeatured(); - const setPrivate = useSetAgentPrivate(); - const toggleFavorite = useToggleAgentFavorite(); - const togglePin = useToggleAgentPin(); + const setPrivate = useSetAgentPrivate(); + const toggleFavorite = useToggleAgentFavorite(); + const togglePin = useToggleAgentPin(); const { skills, isLoading: isSkillsLoading } = useSkills(); const [deleteOpen, setDeleteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); @@ -228,8 +232,8 @@ export function AgentCard({ agent }: AgentCardProps) { // Favoriting collects a square agent into "我的"; pointless for one you // already own, so only offer it on built-in / others' agents. const canFavorite = !isOwned; - const isFavorite = !!agent.is_favorite; - const isPinned = !!agent.is_pinned; + const isFavorite = !!agent.is_favorite; + const isPinned = !!agent.is_pinned; useEffect(() => { if (!editOpen) { @@ -383,7 +387,7 @@ export function AgentCard({ agent }: AgentCardProps) { } } - async function handleToggleFavorite() { + async function handleToggleFavorite() { const next = !isFavorite; try { await toggleFavorite.mutateAsync({ id: agent.id, favorite: next }); @@ -391,17 +395,17 @@ export function AgentCard({ agent }: AgentCardProps) { } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } - } - - async function handleTogglePin() { - const next = !isPinned; - try { - await togglePin.mutateAsync({ id: agent.id, pinned: next }); - toast.success(next ? "已置顶" : "已取消置顶"); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } + } + + async function handleTogglePin() { + const next = !isPinned; + try { + await togglePin.mutateAsync({ id: agent.id, pinned: next }); + toast.success(next ? "已置顶" : "已取消置顶"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } async function handleToggleFeatured() { // One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 @@ -434,274 +438,328 @@ export function AgentCard({ agent }: AgentCardProps) { const enabledSkills = skills.filter((skill) => skill.enabled); - return ( + // Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by + // both the card header and the compact list row. + const statusBadges = ( <> - - -
-
-
- -
-
- - {desensitize(agent.name)} - - {agent.model && ( - - {desensitize(agent.model)} - - )} -
+ {isBuiltin && ( + + 内置 + + )} + {isOwned && ( + + 我的 + + )} + {isFromOthers && ( + + 来自他人 + + )} + {isOwned && agent.published && !agent.is_private && ( + + + 已发布 + + )} + {agent.is_private && ( + + + 私密 + + )} + {agent.featured_order != null && ( + + + 首页推荐 + + )} + + ); + + const skillBadgeItems = [ + ...(agent.tool_groups ?? []).map((group) => ({ + key: `tg:${group}`, + node: ( + + {desensitize(group)} + + ), + })), + ...(agent.skills ?? []).map((skill) => ({ + key: `sk:${skill}`, + node: ( + { + e.stopPropagation(); + setDetailSkillName(skill); + }} + > + {desensitize(skill)} + + ), + })), + ]; + const hasSkillsOrTools = + (agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0; + + const showActionRow = + canEdit || canDelete || isOwned || isAdmin || canFavorite; + + // The icon action buttons, shared by both layouts. + const actionButtons = ( + <> + {canFavorite && ( + + )} + + {isAdmin && ( + + )} + {isAdmin && ( + + )} + {isOwned && ( + + )} + {(isOwned || isAdmin) && ( + + )} + + {canEdit && ( + + )} + {canDelete && ( + + )} + + ); + + const chatButton = ( + + ); + + const cardLayout = ( + + +
+
+
+
-
- {isBuiltin && ( - - 内置 - - )} - {isOwned && ( - - 我的 - - )} - {isFromOthers && ( - - 来自他人 - - )} - {isOwned && agent.published && !agent.is_private && ( - - - 已发布 - - )} - {agent.is_private && ( - - - 私密 - - )} - {agent.featured_order != null && ( - - - 首页推荐 +
+ + {desensitize(agent.name)} + + {agent.model && ( + + {desensitize(agent.model)} )}
- {agent.description && ( - - {desensitize(agent.description)} - - )} - - - {(agent.tags?.length ?? 0) > 0 && ( - - - +
+ {statusBadges} +
+
+ {agent.description && ( + + {desensitize(agent.description)} + )} + - {(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && ( - - ({ - key: `tg:${group}`, - node: ( - - {desensitize(group)} - - ), - })), - ...(agent.skills ?? []).map((skill) => ({ - key: `sk:${skill}`, - node: ( - { - e.stopPropagation(); - setDetailSkillName(skill); - }} - > - {desensitize(skill)} - - ), - })), - ]} - /> - + {(agent.tags?.length ?? 0) > 0 && ( + + + + )} + + {hasSkillsOrTools && ( + + + + )} + + + {showActionRow && ( +
+ {actionButtons} +
)} + {chatButton} +
+ + ); - - {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && ( -
- {canFavorite && ( - - )} - - {isAdmin && ( - - )} - {isAdmin && ( - - )} - {isOwned && ( - - )} - {(isOwned || isAdmin) && ( - - )} - - {canEdit && ( - - )} - {canDelete && ( - - )} -
- )} - -
- + const listLayout = ( +
+
+ +
+
+
+ + {desensitize(agent.name)} + +
{statusBadges}
+
+ {agent.description && ( +

+ {desensitize(agent.description)} +

+ )} +
+
+ {showActionRow && actionButtons} + {chatButton} +
+
+ ); + + return ( + <> + {isList ? listLayout : cardLayout} diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 9a7d94b8..3be26cef 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -11,10 +11,11 @@ import { Pagination } from "@/components/ui/pagination"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; +import { ViewModeToggle } from "@/components/workspace/view-mode-toggle"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; -import { usePrivateSquareStatus } from "@/core/system-settings/hooks"; +import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks"; import { AgentCard } from "./agent-card"; @@ -28,6 +29,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); + const [viewMode, setViewMode] = useViewMode("agent"); const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">( "mine", ); @@ -125,13 +127,20 @@ export function AgentGallery() { setPrivatePassword(null); }; - const renderGrid = (list: Agent[]) => ( -
- {list.map((agent) => ( - - ))} -
- ); + const renderGrid = (list: Agent[]) => + viewMode === "list" ? ( +
+ {list.map((agent) => ( + + ))} +
+ ) : ( +
+ {list.map((agent) => ( + + ))} +
+ ); /** Loading / empty / (grid + pagination) for one paginated tab. */ const renderListBody = (opts: { @@ -308,6 +317,7 @@ export function AgentGallery() { 共 {activeTotal} 个 + {activeTab === "private" && privatePassword && ( + +

+ 当前系统默认:{current === "card" ? "卡片" : "列表"} +

+
+ ))} +
+ + + )} + void }) { const [tagIds, setTagIds] = useState([]); const [sort, setSort] = useState("time_desc"); const [page, setPage] = useState(1); + const [viewMode, setViewMode] = useViewMode("skill"); const reconcileMutation = useReconcileCustomSkills(); // Name + tag filtering is resolved server-side via the /api/skills query. const { skills, isLoading, error } = useSkills({ search, tagIds, sort }); @@ -335,25 +338,28 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { tagIds={tagIds} onTagIdsChange={setTagIds} actions={ - +
+ + +
} /> {isLoading && ( @@ -364,13 +370,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { )} {!isLoading && !error && filteredSkills.length > 0 && ( -
+
{pagedSkills.map((skill) => ( ))}
@@ -427,11 +440,15 @@ export function SkillRow({ skill, currentUserId, isAdmin, + layout = "row", }: { skill: Skill; currentUserId: string | null; isAdmin: boolean; + /** "row" = full-width horizontal item (default); "card" = grid card cell. */ + layout?: "row" | "card"; }) { + const isCard = layout === "card"; const { t } = useI18n(); const setPublished = useSetSkillPublished(); const setPinned = useSetSkillPinned(); @@ -529,207 +546,239 @@ export function SkillRow({ } } + const badgeGroup = ( +
+ + {desensitize(skill.name)} + + {isBuiltin && ( + + {t.settings.skills.badgeBuiltin} + + )} + {isOwned && ( + + {t.settings.skills.badgeMine} + + )} + {isFromOthers && ( + + {t.settings.skills.badgeFromOthers} + + )} + {isOwned && skill.published && ( + + + {t.settings.skills.badgePublished} + + )} + {isCustom && !isBuiltin && skill.source === "agent" && ( + + {t.settings.skills.badgeSourceAgent} + + )} + {isCustom && !isBuiltin && skill.source === "upload" && ( + + {t.settings.skills.badgeSourceUpload} + + )} + {skill.featured && ( + + {t.settings.skills.badgeFeatured} + + )} + {skill.pinned && ( + + {t.settings.skills.badgePinned} + + )} +
+ ); + + const metaNodes = ( + <> + {skill.use_count !== undefined && ( +
+ {skill.use_count > 0 + ? t.settings.skills.useCountInline.replace( + "{count}", + String(skill.use_count), + ) + : t.settings.skills.useCountInlineZero} +
+ )} + {(skill.tags?.length ?? 0) > 0 && ( + + )} + + ); + + const actionButtons = ( + <> + {isAdmin && ( + + )} + {canManage && ( + + )} + {canManage && ( + + )} + {canTogglePublish && ( + + )} + + {canManage && ( + + )} + + ); + return ( <> - - setDetailOpen(true)} - > - -
- {desensitize(skill.name)} - {isBuiltin && ( - - {t.settings.skills.badgeBuiltin} - - )} - {isOwned && ( - - {t.settings.skills.badgeMine} - - )} - {isFromOthers && ( - - {t.settings.skills.badgeFromOthers} - - )} - {isOwned && skill.published && ( - - - {t.settings.skills.badgePublished} - - )} - {isCustom && !isBuiltin && skill.source === "agent" && ( - - {t.settings.skills.badgeSourceAgent} - - )} - {isCustom && !isBuiltin && skill.source === "upload" && ( - - {t.settings.skills.badgeSourceUpload} - - )} - {skill.featured && ( - - {t.settings.skills.badgeFeatured} - - )} - {skill.pinned && ( - - {t.settings.skills.badgePinned} - - )} -
-
- - {desensitize(skill.description ?? "")} - - {skill.use_count !== undefined && ( -
- {skill.use_count > 0 - ? t.settings.skills.useCountInline.replace( - "{count}", - String(skill.use_count), - ) - : t.settings.skills.useCountInlineZero} -
- )} - {(skill.tags?.length ?? 0) > 0 && ( - - )} -
- - {isAdmin && ( - - )} - {canManage && ( - - )} - {canManage && ( - - )} - {canTogglePublish && ( - - )} - - {canManage && ( - - )} - -
+ {badgeGroup} +

+ {desensitize(skill.description ?? "")} +

+ {metaNodes} +
+
+ {actionButtons} +
+
+ ) : ( + + setDetailOpen(true)} + > + {badgeGroup} + + {desensitize(skill.description ?? "")} + + {metaNodes} + + {actionButtons} + + )} diff --git a/frontend-web/src/components/workspace/view-mode-toggle.tsx b/frontend-web/src/components/workspace/view-mode-toggle.tsx new file mode 100644 index 00000000..552a61f5 --- /dev/null +++ b/frontend-web/src/components/workspace/view-mode-toggle.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { LayoutGridIcon, ListIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { ViewMode } from "@/core/system-settings/api"; +import { cn } from "@/lib/utils"; + +/** + * Compact card/list switch shown in the header of the skills and agents lists. + * Purely presentational — the parent owns the `value` (resolved from the user's + * local override or the admin default via `useViewMode`) and persists changes. + */ +export function ViewModeToggle({ + value, + onChange, + className, +}: { + value: ViewMode; + onChange: (mode: ViewMode) => void; + className?: string; +}) { + return ( +
+ + +
+ ); +} diff --git a/frontend-web/src/core/settings/local.ts b/frontend-web/src/core/settings/local.ts index f5db4fa1..cf6de232 100644 --- a/frontend-web/src/core/settings/local.ts +++ b/frontend-web/src/core/settings/local.ts @@ -55,6 +55,13 @@ export interface LocalSettings { * app sizes text/spacing in rem, the whole UI scales proportionally. */ fontScale: number; + /** + * Per-user override for the skill / agent list display mode (card vs list). + * `undefined` = follow the admin-configured system default. Set when the + * user flips the card/list toggle on the skills or agents page. + */ + skillViewMode?: "card" | "list"; + agentViewMode?: "card" | "list"; }; context: Omit< AgentThreadContext, diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index d94e38a4..9b5f3271 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -90,13 +90,18 @@ export async function loadPrivateSquareStatus(): Promise { } export type ThemeMode = "light" | "dark"; +export type ViewMode = "card" | "list"; export interface AppearanceSettings { default_theme: ThemeMode; + default_skill_view: ViewMode; + default_agent_view: ViewMode; } export interface AppearanceUpdate { default_theme?: ThemeMode; + default_skill_view?: ViewMode; + default_agent_view?: ViewMode; } export async function loadAppearanceSettings(): Promise { diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 6cf4f5af..e5a85608 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useLocalSettings } from "@/core/settings"; + import { loadAppearanceSettings, loadCitationDisplaySettings, @@ -16,6 +18,7 @@ import { type LeaderboardSettingsUpdate, type PrivateSquareUpdate, type PromptPrefixUpdate, + type ViewMode, } from "./api"; const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const; @@ -86,6 +89,31 @@ export function useUpdateAppearanceSettings() { }); } +/** + * Effective card/list display mode for the skill or agent list. A user's own + * choice (persisted in local settings) wins; otherwise the admin-configured + * system default applies; otherwise a built-in fallback (skills → list, + * agents → card, matching the historical layout of each page). `setMode` + * persists the user's override locally. + */ +export function useViewMode( + kind: "skill" | "agent", +): [ViewMode, (mode: ViewMode) => void] { + const [settings, setSettings] = useLocalSettings(); + const { data: appearance } = useAppearanceSettings(); + const overrideKey = kind === "skill" ? "skillViewMode" : "agentViewMode"; + const userOverride = settings.display[overrideKey]; + const adminDefault = + kind === "skill" + ? appearance?.default_skill_view + : appearance?.default_agent_view; + const fallback: ViewMode = kind === "skill" ? "list" : "card"; + const mode: ViewMode = userOverride ?? adminDefault ?? fallback; + const setMode = (next: ViewMode) => + setSettings("display", { [overrideKey]: next }); + return [mode, setMode]; +} + export function useCitationDisplaySettings() { return useQuery({ queryKey: CITATION_DISPLAY_KEY, diff --git a/frontend-web/src/pages/ScheduledTasksPage.tsx b/frontend-web/src/pages/ScheduledTasksPage.tsx index e5d3e614..418f807e 100644 --- a/frontend-web/src/pages/ScheduledTasksPage.tsx +++ b/frontend-web/src/pages/ScheduledTasksPage.tsx @@ -751,7 +751,24 @@ export default function ScheduledTasksPage() {
- 执行记录 +
+ 执行记录 + {selectedTask && ( + + )} +