From 1ac126fc9e48815b6efcbcc7cb1b5c9f54a2305f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 15:23:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20D=20=E6=B6=88=E6=81=AF=E7=BC=96?= =?UTF-8?q?=E8=BE=91/=E5=88=A0=E9=99=A4=E5=89=8D=E7=AB=AF=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20+=20=E6=97=A5=E5=BF=97=E8=81=9A=E5=90=88=E5=89=8D?= =?UTF-8?q?=E7=AB=AF(Observability=E5=90=8E=E7=AB=AFtail)=20+=20=E4=BF=AEa?= =?UTF-8?q?pi-client=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D前端: lib/api/messages.ts(patchMessage/deleteMessage,兼容裸DTO与{message}) + MessageActions.tsx(仅user消息; 单击/右键/450ms长按开Miuix玻璃菜单; 编辑弹窗预填+字数→乐观updateMessageById→patchMessage→失败回滚; 删除确认→deleteMessage→removeMessageById; 乐观气泡禁用) + chat.tsx挂载 + Icon加edit/trash/more - 日志前端: types(AdminLogLine time:string|number) + adminSystem.adminObservabilityLogs + Observability.tsx 后端分级tail(level筛选chip注明'该级及以上'+等宽行HH:mm:ss.SSS·level着色·source·msg+30s刷新) 下半保留WS回显 - Docs/api-client §6a/§4.2 修正: PATCH /messages/{id} 实际返回 {data:} 非 {data:{message}}(前端已双向兼容,避免误导Android) - 前端 build 绿(167 modules); SSR冒烟11 checks - 遗留: 未实机登录; 菜单absolute理论上边缘可裁切(可改portal) --- Docs/api-client.md | 4 +- .../web/src/admin/modules/Observability.tsx | 163 +++++++- frontend/web/src/components/Icon.tsx | 11 +- frontend/web/src/components/chat.tsx | 9 +- .../components/messages/MessageActions.tsx | 360 ++++++++++++++++++ frontend/web/src/lib/api.ts | 4 + frontend/web/src/lib/api/adminSystem.ts | 14 + frontend/web/src/lib/api/messages.ts | 36 ++ frontend/web/src/lib/types.ts | 22 ++ 9 files changed, 611 insertions(+), 12 deletions(-) create mode 100644 frontend/web/src/components/messages/MessageActions.tsx create mode 100644 frontend/web/src/lib/api/messages.ts diff --git a/Docs/api-client.md b/Docs/api-client.md index e098a86..e75fcdc 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -29,7 +29,7 @@ | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,lastMessage?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:`;`pinned` 恒返回 bool;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:"`);未注入 HistoryStore 回 `501 not_implemented` | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | -| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ `{data:{message}}`(更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) | +| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) | | 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | | 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两者都改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 | @@ -232,7 +232,7 @@ - **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。 - **搜索**:`q` 非空 = **会话内**内容搜索(倒序,仍支持 `before`/`limit` 分页);空 `q` = 普通分页;`q` 与 `after` 互斥。**不跨会话**。 - 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。 -- **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:{message}}`;`DELETE /api/v1/messages/{id}` → `{data:{deleted:}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。两者成功后都会广播(`message_edited` / `message_deleted`,§3.8),同账号其它端**免刷新**同步。 +- **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:<消息DTO>}`(裸 DTO,无 `message` 包裹);`DELETE /api/v1/messages/{id}` → `{data:{deleted:}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。两者成功后都会广播(`message_edited` / `message_deleted`,§3.8),同账号其它端**免刷新**同步。 - **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,`sessionId` 匹配激活会话才渲染;新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。**消息被编辑/删除**则分别广播 `message_edited` / `message_deleted`(§3.8)——同一 `sessionId` 过滤规则,前者按 `id` 原位改 `content`、后者按 `id` 移除,都不需要重拉整页。 - **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, stream:true}`。`stream:true` 走 WS 流式;非流式返回完整 `reply`(会话正忙时 `202 queued`、排队满 `409 chat_busy`,见 §3.2)。 diff --git a/frontend/web/src/admin/modules/Observability.tsx b/frontend/web/src/admin/modules/Observability.tsx index c8ebdea..6d1f5ab 100644 --- a/frontend/web/src/admin/modules/Observability.tsx +++ b/frontend/web/src/admin/modules/Observability.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useAppStore } from "../../store/useAppStore"; import { useAuditStore } from "../audit"; import { CardGrid, Notice, PageHeader, StatCard } from "../components"; @@ -6,8 +6,13 @@ import { Button, Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; import { useAsyncData } from "../../lib/useAsyncData"; import { useAutoReload } from "../hooks"; -import { adminObservabilityAudit, adminObservabilityMetrics, adminObservabilityTrace } from "../../lib/api"; -import type { AdminTraceSpan, AuditRecord } from "../../lib/types"; +import { + adminObservabilityAudit, + adminObservabilityLogs, + adminObservabilityMetrics, + adminObservabilityTrace, +} from "../../lib/api"; +import type { AdminLogLevel, AdminLogLine, AdminTraceSpan, AuditRecord } from "../../lib/types"; type Tab = "logs" | "trace" | "metrics" | "audit"; @@ -18,6 +23,38 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [ { id: "audit", label: "审计" }, ]; +/** Live backend log tail (GET /admin/observability/logs): level filter + refresh. */ +const LOG_LIMIT = 200; +const LOG_RELOAD_MS = 30_000; + +const LEVEL_FILTERS: ReadonlyArray<{ id: AdminLogLevel | ""; label: string }> = [ + { id: "", label: "全部" }, + { id: "debug", label: "debug" }, + { id: "info", label: "info" }, + { id: "warn", label: "warn" }, + { id: "error", label: "error" }, +]; + +const LEVEL_TEXT: Record = { + debug: "text-[var(--mu-text-lo)]", + info: "text-[var(--mu-accent-hi)]", + warn: "text-[var(--mu-warn)]", + error: "text-[var(--mu-err)]", +}; + +/** `time` is RFC3339 on current builds but the contract allows epoch ms too, so + * both are normalized; an unparseable value is shown verbatim rather than faked. */ +function fmtLogTime(t: string | number): string { + const d = new Date(t); + if (Number.isNaN(d.getTime())) return String(t); + const p = (n: number, w = 2) => String(n).padStart(w, "0"); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`; +} + +function levelTextClass(level: string): string { + return LEVEL_TEXT[level.toLowerCase()] ?? "text-[var(--mu-text-lo)]"; +} + const KIND_LABEL: Record = { llm_call: "LLM", tool_call: "工具", @@ -52,7 +89,7 @@ export function ObservabilityModule(): React.ReactNode { ))} - {tab === "logs" ? : null} + {tab === "logs" ? : null} {tab === "trace" ? : null} {tab === "metrics" ? : null} {tab === "audit" ? : null} @@ -60,12 +97,124 @@ export function ObservabilityModule(): React.ReactNode { ); } +/** 实时日志 tab: the backend's structured log tail first (authoritative, leveled + * and filtered server-side), then the WS event echo as a client-side complement. */ +function LogsTab({ lines }: { lines: string[] }): React.ReactNode { + return ( +
+ + +
+ ); +} + +/** Backend log tail: level filter chips over a monospace list, refreshed every + * 30s. Loading / empty / error are each surfaced honestly — no placeholder rows. */ +function BackendLogs(): React.ReactNode { + const [level, setLevel] = useState(""); + const logs = useAsyncData( + () => adminObservabilityLogs({ level: level || undefined, limit: LOG_LIMIT }), + [level] + ); + useAutoReload(logs.reload, LOG_RELOAD_MS); + const rows = logs.data ?? []; + + // "更新于 N 秒前" needs a ticking clock; the interval only forces a re-render, + // the timestamp itself is captured when fresh data lands. + const [updatedAt, setUpdatedAt] = useState(0); + useEffect(() => { + if (logs.data) setUpdatedAt(Date.now()); + }, [logs.data]); + const [, setTick] = useState(0); + useEffect(() => { + const id = window.setInterval(() => setTick((v) => v + 1), 1000); + return () => window.clearInterval(id); + }, []); + + return ( + +
+

实时日志(后端分级 tail)

+ tail + + GET /api/v1/admin/observability/logs + + + {updatedAt > 0 ? `${Math.max(0, Math.round((Date.now() - updatedAt) / 1000))}s 前更新` : "尚未更新"} + {" · 30s 自动刷新"} + +
+ +
+ {LEVEL_FILTERS.map((f) => ( + + ))} + + 共 {rows.length} 条 · 上限 {LOG_LIMIT} + {level ? ` · 筛选 ${level} 及以上` : ""} + +
+ + {logs.loading && logs.data === null ? ( + 加载日志… + ) : logs.error && logs.data === null ? ( + 日志端点暂不可用:{logs.error} + ) : rows.length === 0 ? ( + + 暂无日志{level ? `(level=${level})` : ""}。网关写入结构化日志后,此处按 level 过滤实时展示。 + + ) : ( +
+ {rows.map((line, i) => ( + + ))} +
+ )} + {logs.error && logs.data !== null ? ( + 上次刷新失败(保留现有日志):{logs.error} + ) : null} +
+ ); +} + +/** One monospace log line: time · level · source · msg (source never blank). */ +function LogRow({ line }: { line: AdminLogLine }): React.ReactNode { + return ( +
+ + {fmtLogTime(line.time)} + + + {line.level || "info"} + + {line.source || "—"} + + {line.msg} + +
+ ); +} + +/** Client-side WS event echo — the frames this browser received, kept alongside + * the server log because the two answer different questions (server truth vs. + * what this client actually saw). */ function LogsTail({ lines }: { lines: string[] }): React.ReactNode { return (
-

实时日志流(WS 事件回显)

- tail +

本端 WS 事件回显

+ client
{lines.length === 0 ? ( 暂无实时事件。接入 /ws/chat 后,此处回显 stream / tool / device_update / reminder 等事件。 @@ -74,7 +223,7 @@ function LogsTail({ lines }: { lines: string[] }): React.ReactNode { {lines.join("\n")} )} - 完整后端日志分级 tail / 过滤 / 导出需后端 M10+ 提供。 + 这是当前浏览器的 WS 帧回显,不是服务端日志;服务端分级日志见上方「实时日志(后端分级 tail)」。
); } diff --git a/frontend/web/src/components/Icon.tsx b/frontend/web/src/components/Icon.tsx index cf171c6..40d1d04 100644 --- a/frontend/web/src/components/Icon.tsx +++ b/frontend/web/src/components/Icon.tsx @@ -47,7 +47,10 @@ export type IconName = | "terminal" // 🖥 code (terminal_rounded) | "image" // 🖼 attachment/vision (image_rounded) | "phone" // 📞 voice call / hang up (call_rounded) - | "menu" // ☰ drawer/open nav (menu_rounded); + | "menu" // ☰ drawer/open nav (menu_rounded) + | "edit" // ✎ edit a message (edit_rounded) + | "trash" // 🗑 delete a message (delete_rounded) + | "more" // ⋯ message action menu (more_horiz_rounded); const ICON_PATHS: Record = { chart: @@ -128,6 +131,12 @@ const ICON_PATHS: Record = { "M6.6 10.8a15.1 15.1 0 0 0 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.3 0 .7-.2 1l-2.3 2.2Z", menu: "M4 6h16v2H4V6Zm0 5h16v2H4v-2Zm0 5h16v2H4v-2Z", + edit: + "M3 17.25V21h3.75L17.8 9.94l-3.75-3.75L3 17.25ZM20.7 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83Z", + trash: + "M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12ZM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4Z", + more: + "M6 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z", }; export interface IconProps { diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index b9dba6a..989d4d1 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -6,6 +6,7 @@ import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; import { WakePath } from "./status"; import { modeBody } from "./messages"; +import { UserMessageActions } from "./messages/MessageActions"; import { renderContent } from "../lib/renderContent"; import { splitBubble, type ContentPart } from "../lib/parseContent"; @@ -332,7 +333,9 @@ export function SplitChatBubble({ // bubble and below the last — never inside a bubble's body. const align = message.role === "user" ? "right" : "left"; return ( -
+
{segments.map((seg, i) => message.role === "user" ? ( @@ -348,6 +351,7 @@ export function SplitChatBubble({ ), )} + {message.role === "user" ? : null}
); } @@ -362,7 +366,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re {renderContent(message.content)} ) : null; return ( -
+
{message.image ? ( +
); } diff --git a/frontend/web/src/components/messages/MessageActions.tsx b/frontend/web/src/components/messages/MessageActions.tsx new file mode 100644 index 0000000..34dcf27 --- /dev/null +++ b/frontend/web/src/components/messages/MessageActions.tsx @@ -0,0 +1,360 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import type { ChatMessage } from "../../lib/types"; +import { deleteMessage, patchMessage } from "../../lib/api"; +import { useAppStore } from "../../store/useAppStore"; +import { Button } from "../primitives"; +import { Icon } from "../Icon"; + +/** + * 用户消息的编辑 / 删除入口(design/16 §4.2 D3)。只对 **user 角色** 且已落库 + * (有 sessionId)的消息开放:assistant 回复不可编辑;尚在本机乐观渲染、后端还没 + * 分配 id 的气泡也没有可 PATCH 的目标。 + * + * 写路径的真实性:编辑走 `PATCH /messages/{id}`、删除走 `DELETE /messages/{id}`, + * 两者成功后后端都会广播 `message_edited` / `message_deleted`(lib/ws.ts 已消费), + * 所以本组件只做「先乐观、失败回滚」(编辑)与「成功后就地移除」(删除), + * 不伪造成功、不静默吞掉错误——失败一律把后端文案展示出来。 + */ + +/** Backend cap on a message body (runes), mirrored for the inline counter. */ +const MAX_CONTENT_RUNES = 4000; +/** How long a touch must hold the trigger before the menu opens (ms). */ +const LONG_PRESS_MS = 450; + +function runeLen(s: string): number { + return [...s].length; +} + +/** Miuix modal shell: dimmed scrim + a glass card. Clicking the scrim or pressing + * Escape cancels; the card itself stops propagation. */ +function Modal({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: ReactNode; +}): ReactNode { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( +
+
+
+

{title}

+ {children} +
+
+ ); +} + +/** Inline error line shown inside a dialog when the backend rejects the write. */ +function DialogError({ text }: { text: string | null }): ReactNode { + if (!text) return null; + return ( +

+ {text} +

+ ); +} + +/** Edit dialog: prefilled textarea → PATCH. Optimistically patches the store so + * the bubble updates instantly, then normalizes with the backend DTO; a failure + * rolls the old content back and keeps the dialog open for a retry. */ +function EditMessageDialog({ + message, + onClose, +}: { + message: ChatMessage; + onClose: () => void; +}): ReactNode { + const updateMessageById = useAppStore((s) => s.updateMessageById); + const [draft, setDraft] = useState(message.content); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const len = runeLen(draft); + const invalid = draft.trim() === "" || len > MAX_CONTENT_RUNES; + + const save = async () => { + if (invalid || busy) return; + const previous = message.content; + setBusy(true); + setError(null); + updateMessageById(message.id, { content: draft }); + try { + const updated = await patchMessage(message.id, draft); + updateMessageById(message.id, { content: updated.content }); + onClose(); + } catch (err) { + updateMessageById(message.id, { content: previous }); + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setBusy(false); + } + }; + + return ( + {} : onClose}> +