diff --git a/api/ai.py b/api/ai.py index da5bd44c..34d36e08 100644 --- a/api/ai.py +++ b/api/ai.py @@ -82,7 +82,7 @@ async def generate_images( identity = require_identity(authorization) payload = body.model_dump(mode="python") payload["base_url"] = resolve_image_base_url(request) - call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图") + call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt) await filter_or_log(call, body.prompt) return await call.run(openai_v1_image_generations.handle, payload) @@ -100,7 +100,7 @@ async def edit_images( stream: bool | None = Form(default=None), ): identity = require_identity(authorization) - call = LoggedCall(identity, "/v1/images/edits", model, "图生图") + call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt) if n < 1 or n > 4: raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"}) await filter_or_log(call, prompt) @@ -130,8 +130,9 @@ async def create_chat_completion(body: ChatCompletionRequest, authorization: str identity = require_identity(authorization) payload = body.model_dump(mode="python") model = str(payload.get("model") or "auto") - call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成") - await filter_or_log(call, request_text(payload.get("prompt"), payload.get("messages"))) + request_preview = request_text(payload.get("prompt"), payload.get("messages")) + call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成", request_text=request_preview) + await filter_or_log(call, request_preview) return await call.run(openai_v1_chat_complete.handle, payload) @router.post("/v1/responses") @@ -139,8 +140,9 @@ async def create_response(body: ResponseCreateRequest, authorization: str | None identity = require_identity(authorization) payload = body.model_dump(mode="python") model = str(payload.get("model") or "auto") - call = LoggedCall(identity, "/v1/responses", model, "Responses") - await filter_or_log(call, request_text(payload.get("input"), payload.get("instructions"))) + request_preview = request_text(payload.get("input"), payload.get("instructions")) + call = LoggedCall(identity, "/v1/responses", model, "Responses", request_text=request_preview) + await filter_or_log(call, request_preview) return await call.run(openai_v1_response.handle, payload) @router.post("/v1/messages") @@ -153,8 +155,9 @@ async def create_message( identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None)) payload = body.model_dump(mode="python") model = str(payload.get("model") or "auto") - call = LoggedCall(identity, "/v1/messages", model, "Messages") - await filter_or_log(call, request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))) + request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools")) + call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview) + await filter_or_log(call, request_preview) return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic") return router diff --git a/api/image_tasks.py b/api/image_tasks.py index f7edcaa2..1005241c 100644 --- a/api/image_tasks.py +++ b/api/image_tasks.py @@ -47,7 +47,7 @@ async def create_generation_task( authorization: str | None = Header(default=None), ): identity = require_identity(authorization) - await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务"), body.prompt) + await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt) try: return await run_in_threadpool( image_task_service.submit_generation, @@ -73,7 +73,7 @@ async def create_edit_task( size: str | None = Form(default=None), ): identity = require_identity(authorization) - await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务"), prompt) + await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt) uploads = [*(image or []), *(image_list or [])] if not uploads: raise HTTPException(status_code=400, detail={"error": "image file is required"}) diff --git a/api/system.py b/api/system.py index 74d0cba1..ec38374d 100644 --- a/api/system.py +++ b/api/system.py @@ -26,6 +26,10 @@ class ImageDeleteRequest(BaseModel): all_matching: bool = False +class LogDeleteRequest(BaseModel): + ids: list[str] = [] + + def create_router(app_version: str) -> APIRouter: router = APIRouter() @@ -73,6 +77,11 @@ async def get_logs(type: str = "", start_date: str = "", end_date: str = "", aut require_admin(authorization) return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())} + @router.post("/api/logs/delete") + async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + return log_service.delete(body.ids) + @router.post("/api/proxy/test") async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)): require_admin(authorization) diff --git a/services/image_task_service.py b/services/image_task_service.py index f2e72348..69a178d1 100644 --- a/services/image_task_service.py +++ b/services/image_task_service.py @@ -9,6 +9,7 @@ from typing import Any from services.config import DATA_DIR, config +from services.content_filter import request_text from services.log_service import LOG_TYPE_CALL, log_service from services.protocol import openai_v1_image_edit, openai_v1_image_generations @@ -232,11 +233,28 @@ def _run_task( message = _clean(result.get("message")) or "image task returned no image data" raise RuntimeError(message) self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="") - self._log_call(identity, mode, model, started, "调用完成", urls=_collect_image_urls(data)) + self._log_call( + identity, + mode, + model, + started, + "调用完成", + request_preview=request_text(payload.get("prompt")), + urls=_collect_image_urls(data), + ) except Exception as exc: error_message = str(exc) or "image task failed" self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[]) - self._log_call(identity, mode, model, started, "调用失败", status="failed", error=error_message) + self._log_call( + identity, + mode, + model, + started, + "调用失败", + request_preview=request_text(payload.get("prompt")), + status="failed", + error=error_message, + ) def _log_call( self, @@ -246,6 +264,7 @@ def _log_call( started: float, suffix: str, *, + request_preview: str = "", status: str = "success", error: str = "", urls: list[str] | None = None, @@ -263,6 +282,8 @@ def _log_call( "duration_ms": int((time.time() - started) * 1000), "status": status, } + if request_preview: + detail["request_text"] = request_preview if error: detail["error"] = error if urls: diff --git a/services/log_service.py b/services/log_service.py index 390d3ddc..63d04d5d 100644 --- a/services/log_service.py +++ b/services/log_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import itertools import time @@ -7,6 +8,7 @@ from datetime import datetime from pathlib import Path from typing import Any +from uuid import uuid4 from fastapi import HTTPException from fastapi.concurrency import run_in_threadpool @@ -24,38 +26,87 @@ def __init__(self, path: Path): self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) + @staticmethod + def _legacy_id(raw_line: str, line_number: int) -> str: + payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore") + return hashlib.sha1(payload).hexdigest()[:24] + + def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None: + try: + item = json.loads(raw_line) + except Exception: + return None + if not isinstance(item, dict): + return None + parsed = dict(item) + parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number)) + return parsed + + @staticmethod + def _serialize_item(item: dict[str, Any]) -> str: + return json.dumps(item, ensure_ascii=False, separators=(",", ":")) + + @staticmethod + def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool: + t = str(item.get("time") or "") + day = t[:10] + if type and item.get("type") != type: + return False + if start_date and day < start_date: + return False + if end_date and day > end_date: + return False + return True + def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None: item = { + "id": uuid4().hex, "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "type": type, "summary": summary, "detail": detail or data, } with self.path.open("a", encoding="utf-8") as file: - file.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n") + file.write(self._serialize_item(item) + "\n") def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]: if not self.path.exists(): return [] items: list[dict[str, Any]] = [] - for line in reversed(self.path.read_text(encoding="utf-8").splitlines()): - try: - item = json.loads(line) - except Exception: - continue - t = str(item.get("time") or "") - day = t[:10] - if type and item.get("type") != type: + lines = self.path.read_text(encoding="utf-8").splitlines() + for line_number in range(len(lines) - 1, -1, -1): + item = self._parse_line(lines[line_number], line_number) + if item is None: continue - if start_date and day < start_date: - continue - if end_date and day > end_date: + if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date): continue items.append(item) if len(items) >= limit: break return items + def delete(self, ids: list[str]) -> dict[str, int]: + target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()} + if not self.path.exists() or not target_ids: + return {"removed": 0} + lines = self.path.read_text(encoding="utf-8").splitlines() + kept_lines: list[str] = [] + removed = 0 + for line_number, raw_line in enumerate(lines): + item = self._parse_line(raw_line, line_number) + if item is None: + kept_lines.append(raw_line) + continue + if str(item.get("id") or "") in target_ids: + removed += 1 + continue + kept_lines.append(self._serialize_item(item)) + content = "\n".join(kept_lines) + if content: + content += "\n" + self.path.write_text(content, encoding="utf-8") + return {"removed": removed} + log_service = LogService(DATA_DIR / "logs.jsonl") @@ -76,6 +127,16 @@ def _collect_urls(value: object) -> list[str]: return urls +def _request_excerpt(text: object, limit: int = 1000) -> str: + value = str(text or "").strip() + if not value: + return "" + normalized = " ".join(value.split()) + if len(normalized) <= limit: + return normalized + return normalized[: limit - 1].rstrip() + "…" + + def _image_error_response(exc: Exception) -> JSONResponse: message = str(exc) if "no available image quota" in message.lower(): @@ -119,6 +180,7 @@ class LoggedCall: model: str summary: str started: float = field(default_factory=time.time) + request_text: str = "" async def run(self, handler, *args, sse: str = "openai"): from services.protocol.conversation import ImageGenerationError @@ -184,6 +246,9 @@ def log(self, suffix: str, result: object = None, status: str = "success", error "duration_ms": int((time.time() - self.started) * 1000), "status": status, } + request_excerpt = _request_excerpt(self.request_text) + if request_excerpt: + detail["request_text"] = request_excerpt if error: detail["error"] = error collected_urls = [*(urls or []), *_collect_urls(result)] diff --git a/web/src/app/logs/page.tsx b/web/src/app/logs/page.tsx index 6c80ebea..0f1dfca2 100644 --- a/web/src/app/logs/page.tsx +++ b/web/src/app/logs/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { ChevronLeft, ChevronRight, ImageIcon, LoaderCircle, RefreshCw, Search } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight, ImageIcon, LoaderCircle, RefreshCw, Search, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { DateRangeFilter } from "@/components/date-range-filter"; @@ -10,10 +10,11 @@ import { ImageThumbnail, getImageThumbnailUrl } from "@/components/image-thumbna import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { fetchSystemLogs, type SystemLog } from "@/lib/api"; +import { deleteSystemLogs, fetchSystemLogs, type SystemLog } from "@/lib/api"; import { useAuthGuard } from "@/lib/use-auth-guard"; const LogType = { @@ -59,6 +60,9 @@ function LogsContent() { const [lightboxOpen, setLightboxOpen] = useState(false); const [page, setPage] = useState(1); const [isLoading, setIsLoading] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [deletingItems, setDeletingItems] = useState([]); const detailUrls = getUrls(detailLog); const detailImages = detailUrls.map((url, index) => ({ id: `${index}`, src: url })); const isCallLog = type === LogType.Call; @@ -66,17 +70,21 @@ function LogsContent() { const pageCount = Math.max(1, Math.ceil(items.length / pageSize)); const safePage = Math.min(page, pageCount); const currentRows = items.slice((safePage - 1) * pageSize, safePage * pageSize); + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const currentPageSelected = currentRows.length > 0 && currentRows.every((item) => selectedSet.has(item.id)); + const allSelected = items.length > 0 && items.every((item) => selectedSet.has(item.id)); const loadLogs = async () => { setIsLoading(true); try { const data = await fetchSystemLogs({ type, start_date: startDate, end_date: endDate }); setItems(data.items); + setSelectedIds((current) => current.filter((id) => data.items.some((item) => item.id === id))); setPage(1); } catch (error) { toast.error(error instanceof Error ? error.message : "加载日志失败"); } finally { - setIsLoading(false); + setIsLoading(false); } }; @@ -96,6 +104,31 @@ function LogsContent() { setLightboxOpen(true); }; + const toggleIds = (ids: string[], checked: boolean) => { + setSelectedIds((current) => checked ? Array.from(new Set([...current, ...ids])) : current.filter((id) => !ids.includes(id))); + }; + + const confirmDelete = async () => { + const ids = deletingItems.map((item) => item.id); + if (ids.length === 0) return; + setIsDeleting(true); + try { + const data = await deleteSystemLogs(ids); + toast.success(`已删除 ${data.removed} 条日志`); + setDeletingItems([]); + setSelectedIds((current) => current.filter((id) => !ids.includes(id))); + if (detailLog && ids.includes(detailLog.id)) { + setDetailOpen(false); + setDetailLog(null); + } + await loadLogs(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除日志失败"); + } finally { + setIsDeleting(false); + } + }; + useEffect(() => { void loadLogs(); }, [type, startDate, endDate]); @@ -128,17 +161,38 @@ function LogsContent() { -
- 共 {items.length} 条 - +
+
+ 共 {items.length} 条 + + + {selectedIds.length > 0 ? 已选 {selectedIds.length} 条 : null} +
+
+ + + +
- +
+ 时间 类型 {isCallLog ? 令牌名称 : null} @@ -146,14 +200,17 @@ function LogsContent() { {isCallLog ? 状态 : null} {isCallLog ? 图片 : null} 简述 - 详情 + 操作 - {currentRows.map((item, index) => { + {currentRows.map((item) => { const urls = getUrls(item); return ( - + + + toggleIds([item.id], Boolean(checked))} /> + {item.time} {typeLabels[item.type] || item.type} {isCallLog ? {getDetailText(item, "key_name")} : null} @@ -192,9 +249,14 @@ function LogsContent() { ) : null} {item.summary || "-"} - +
+ + +
); @@ -215,40 +277,44 @@ function LogsContent() { - - + + 日志详情 -
- {Object.entries(detailLog?.detail || {}) - .filter(([key, value]) => key !== "urls" && typeof value !== "object") - .map(([key, value]) => ( -
- {key} - {String(value)} +
+
+
+ {Object.entries(detailLog?.detail || {}) + .filter(([key, value]) => key !== "urls" && typeof value !== "object") + .map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+ {detailUrls.length ? ( +
+ {detailUrls.map((url, index) => ( + + ))}
- ))} -
- {detailUrls.length ? ( -
- {detailUrls.map((url, index) => ( - - ))} + ) : null} +
+                {JSON.stringify(detailLog?.detail || {}, null, 2)}
+              
- ) : null} -
-            {JSON.stringify(detailLog?.detail || {}, null, 2)}
-          
+
+ 0} onOpenChange={(open) => (!open ? setDeletingItems([]) : null)}> + + + {deletingItems.length === 1 ? "删除日志" : "删除所选日志"} + + 确认删除 {deletingItems.length} 条日志吗?删除后无法恢复。 + + + + + + + + ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 42f5530c..fc6ceab6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -83,6 +83,7 @@ export type ManagedImage = { }; export type SystemLog = { + id: string; time: string; type: "call" | "account" | string; summary?: string; @@ -342,6 +343,13 @@ export async function fetchSystemLogs(filters: { type?: string; start_date?: str return httpRequest<{ items: SystemLog[] }>(`/api/logs${params.toString() ? `?${params.toString()}` : ""}`); } +export async function deleteSystemLogs(ids: string[]) { + return httpRequest<{ removed: number }>("/api/logs/delete", { + method: "POST", + body: { ids }, + }); +} + export async function fetchUserKeys() { return httpRequest<{ items: UserKey[] }>("/api/auth/users"); }