feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:<DTO>} 非 {data:{message}}(前端已双向兼容,避免误导Android)
- 前端 build 绿(167 modules); SSR冒烟11 checks
- 遗留: 未实机登录; 菜单absolute理论上边缘可裁切(可改portal)
This commit is contained in:
AskaEth committed 2026-10-03 15:23:27 +08:00
1 parent 025dd1796b
commit 1ac126fc9e
9 files changed
+611 -12

No files matched your search

+2 -2
View File
@@ -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:<uuid>`;`pinned` 恒返回 bool;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:<uuid>"`);未注入 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:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `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:<id>}}`。不存在回 `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:<id>}}`。不存在回 `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)。
@@ -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<string, string> = {
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<string, string> = {
llm_call: "LLM",
tool_call: "工具",
@@ -52,7 +89,7 @@ export function ObservabilityModule(): React.ReactNode {
))}
</div>
{tab === "logs" ? <LogsTail lines={deviceUpdates} /> : null}
{tab === "logs" ? <LogsTab lines={deviceUpdates} /> : null}
{tab === "trace" ? <TraceView /> : null}
{tab === "metrics" ? <MetricsView /> : null}
{tab === "audit" ? <AuditView records={audit} onClear={clearAudit} /> : 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 (
<div className="space-y-4">
<BackendLogs />
<LogsTail lines={lines} />
</div>
);
}
/** 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<AdminLogLevel | "">("");
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 (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">实时日志(后端分级 tail)</h3>
<Tag tone="ok">tail</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">
GET /api/v1/admin/observability/logs
</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
{updatedAt > 0 ? `${Math.max(0, Math.round((Date.now() - updatedAt) / 1000))}s 前更新` : "尚未更新"}
{" · 30s 自动刷新"}
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{LEVEL_FILTERS.map((f) => (
<button
key={f.id || "all"}
onClick={() => setLevel(f.id)}
aria-pressed={level === f.id}
className={`rounded-[var(--mu-radius)] px-2.5 py-1 font-mono text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
level === f.id
? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]"
: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-5)]"
}`}
>
{f.label}
</button>
))}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
共 {rows.length} 条 · 上限 {LOG_LIMIT}
{level ? ` · 筛选 ${level} 及以上` : ""}
</span>
</div>
{logs.loading && logs.data === null ? (
<Notice>加载日志…</Notice>
) : logs.error && logs.data === null ? (
<Notice tone="warn">日志端点暂不可用:{logs.error}</Notice>
) : rows.length === 0 ? (
<Notice>
暂无日志{level ? `(level=${level})` : ""}。网关写入结构化日志后,此处按 level 过滤实时展示。
</Notice>
) : (
<div className="max-h-96 overflow-auto rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] py-1.5">
{rows.map((line, i) => (
<LogRow key={`${line.time}-${line.source}-${i}`} line={line} />
))}
</div>
)}
{logs.error && logs.data !== null ? (
<Notice tone="warn">上次刷新失败(保留现有日志):{logs.error}</Notice>
) : null}
</Surface>
);
}
/** One monospace log line: time · level · source · msg (source never blank). */
function LogRow({ line }: { line: AdminLogLine }): React.ReactNode {
return (
<div className="flex items-start gap-2 px-3 py-0.5 font-mono text-xs leading-relaxed hover:bg-[var(--mu-surface-3)]/60">
<span className="shrink-0 text-[var(--mu-text-lo)]" title={String(line.time)}>
{fmtLogTime(line.time)}
</span>
<span className={`w-12 shrink-0 font-semibold ${levelTextClass(line.level)}`}>
{line.level || "info"}
</span>
<span className="shrink-0 text-[var(--mu-accent-hi)]">{line.source || "—"}</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-[var(--mu-text-mid)]">
{line.msg}
</span>
</div>
);
}
/** 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 (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">实时日志流(WS 事件回显)</h3>
<Tag tone="ok">tail</Tag>
<h3 className="text-sm font-semibold">本端 WS 事件回显</h3>
<Tag tone="neutral">client</Tag>
</div>
{lines.length === 0 ? (
<Notice>暂无实时事件。接入 /ws/chat 后,此处回显 stream / tool / device_update / reminder 等事件。</Notice>
@@ -74,7 +223,7 @@ function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
{lines.join("\n")}
</pre>
)}
<Notice>完整后端日志分级 tail / 过滤 / 导出需后端 M10+ 提供。</Notice>
<Notice>这是当前浏览器的 WS 帧回显,不是服务端日志;服务端分级日志见上方「实时日志(后端分级 tail)」。</Notice>
</Surface>
);
}
+10 -1
View File
@@ -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<IconName, string> = {
chart:
@@ -128,6 +131,12 @@ const ICON_PATHS: Record<IconName, string> = {
"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 {
+7 -2
View File
@@ -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 (
<div className={`flex flex-col gap-2.5 ${message.role === "user" ? "items-end" : ""}`}>
<div
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
>
<BubbleNotes items={head} align={align} />
{segments.map((seg, i) =>
message.role === "user" ? (
@@ -348,6 +351,7 @@ export function SplitChatBubble({
),
)}
<BubbleNotes items={tail} align={align} />
{message.role === "user" ? <UserMessageActions message={message} /> : null}
</div>
);
}
@@ -362,7 +366,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
) : null;
return (
<div className="mu-rise-in flex flex-col items-end gap-0.5">
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
{message.image ? (
<img
@@ -375,6 +379,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
</div>
<SourceLabel deviceId={message.deviceId} role={message.role} />
<SenderLabel name={message.senderName} />
<UserMessageActions message={message} />
</div>
);
}
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
onClick={onClose}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="liquid-glass relative w-full max-w-md rounded-[var(--mu-radius-lg)] p-5 shadow-[var(--mu-shadow-2)]"
>
<h3 className="mb-3 text-sm font-semibold text-[var(--mu-text-hi)]">{title}</h3>
{children}
</div>
</div>
);
}
/** 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 (
<p className="mt-2 rounded-[var(--mu-radius-sm)] bg-[var(--mu-err)]/15 px-2.5 py-1.5 text-xs text-[var(--mu-err)]">
{text}
</p>
);
}
/** 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<string | null>(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 (
<Modal title="编辑消息" onClose={busy ? () => {} : onClose}>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={5}
autoFocus
className="w-full resize-y rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm text-[var(--mu-text-hi)] outline-none ring-0 focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<div className="mt-1.5 flex items-center justify-between text-[11px] text-[var(--mu-text-lo)]">
<span>{invalid && draft.trim() === "" ? "内容不能为空" : "仅修改本条消息的内容"}</span>
<span className={len > MAX_CONTENT_RUNES ? "text-[var(--mu-err)]" : ""}>
{len}/{MAX_CONTENT_RUNES}
</span>
</div>
<DialogError text={error} />
<div className="mt-4 flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button size="sm" variant="primary" onClick={() => void save()} disabled={busy || invalid}>
{busy ? "保存中…" : "保存"}
</Button>
</div>
</Modal>
);
}
/** Delete confirm → DELETE. The row is removed from the store only after the
* backend confirms (the WS `message_deleted` broadcast then re-applies it as a
* no-op), so a rejected delete never leaves the UI lying. */
function DeleteMessageDialog({
message,
onClose,
}: {
message: ChatMessage;
onClose: () => void;
}): ReactNode {
const removeMessageById = useAppStore((s) => s.removeMessageById);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
await deleteMessage(message.id);
removeMessageById(message.id);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "删除失败");
} finally {
setBusy(false);
}
};
return (
<Modal title="删除消息" onClose={busy ? () => {} : onClose}>
<p className="whitespace-pre-wrap break-words text-xs text-[var(--mu-text-mid)]">
删除后本条消息会从所有已连接的客户端移除,且无法恢复。
</p>
<DialogError text={error} />
<div className="mt-4 flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button size="sm" variant="danger" onClick={() => void confirm()} disabled={busy}>
{busy ? "删除中…" : "删除"}
</Button>
</div>
</Modal>
);
}
/** One row of the action menu: icon + label, optional disabled reason. */
function MenuItem({
icon,
label,
danger = false,
disabled = false,
title,
onClick,
}: {
icon: "edit" | "trash";
label: string;
danger?: boolean;
disabled?: boolean;
title?: string;
onClick: () => void;
}): ReactNode {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
title={title}
onClick={onClick}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors duration-150 disabled:opacity-40 disabled:pointer-events-none ${
danger
? "text-[var(--mu-err)] hover:bg-[var(--mu-err)]/10"
: "text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<Icon name={icon} size={14} />
{label}
</button>
);
}
/** The "⋯" trigger. Desktop reveals it on row hover (`group-hover`); a coarse
* pointer keeps it faintly visible. Opens on click, right-click, or a 450ms
* long press — a long press swallows the trailing click so it does not toggle
* the menu straight back shut. */
function ActionTrigger({
open,
onToggle,
onOpen,
}: {
open: boolean;
onToggle: () => void;
onOpen: () => void;
}): ReactNode {
const pressTimer = useRef<number | null>(null);
const longPressed = useRef(false);
const clearPress = () => {
if (pressTimer.current !== null) {
window.clearTimeout(pressTimer.current);
pressTimer.current = null;
}
};
useEffect(() => clearPress, []);
return (
<button
type="button"
aria-label="消息操作"
aria-haspopup="menu"
aria-expanded={open}
title="消息操作"
onClick={() => {
if (longPressed.current) {
longPressed.current = false;
return;
}
onToggle();
}}
onPointerDown={() => {
longPressed.current = false;
clearPress();
pressTimer.current = window.setTimeout(() => {
longPressed.current = true;
onOpen();
}, LONG_PRESS_MS);
}}
onPointerUp={clearPress}
onPointerLeave={clearPress}
onPointerCancel={clearPress}
onContextMenu={(e) => {
e.preventDefault();
onOpen();
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--mu-surface-4)]/80 text-[var(--mu-text-mid)] opacity-0 backdrop-blur transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] focus-visible:opacity-100 group-hover:opacity-100 max-sm:opacity-80"
>
<Icon name="more" size={16} />
</button>
);
}
/** The popup itself: a scrim that dismisses on outside click + the 编辑/删除 rows.
* Both rows are disabled until the message has a server id to act on. */
function ActionMenu({
persisted,
onDismiss,
onEdit,
onDelete,
}: {
persisted: boolean;
onDismiss: () => void;
onEdit: () => void;
onDelete: () => void;
}): ReactNode {
const disabledHint = "消息尚未写入后端,稍后再试";
return (
<>
<div className="fixed inset-0 z-10" onClick={onDismiss} aria-hidden />
<div
role="menu"
className="liquid-glass absolute right-0 top-8 z-20 w-28 overflow-hidden rounded-[var(--mu-radius)] py-1 shadow-[var(--mu-shadow-2)]"
>
<MenuItem
icon="edit"
label="编辑"
disabled={!persisted}
title={persisted ? undefined : disabledHint}
onClick={onEdit}
/>
<MenuItem
icon="trash"
label="删除"
danger
disabled={!persisted}
title={persisted ? undefined : disabledHint}
onClick={onDelete}
/>
</div>
</>
);
}
/**
* Hover / long-press action entry for one own user message. Must be rendered
* inside a `relative group` container (the bubble row), which it anchors to.
* Unpersisted optimistic bubbles expose the menu but disable both actions — there
* is no server id to PATCH/DELETE yet.
*/
export function UserMessageActions({ message }: { message: ChatMessage }): ReactNode {
const [menuOpen, setMenuOpen] = useState(false);
const [dialog, setDialog] = useState<"edit" | "delete" | null>(null);
const openDialog = (next: "edit" | "delete") => {
setMenuOpen(false);
setDialog(next);
};
const closeDialog = () => setDialog(null);
// Assistant / system messages are never editable from the user surface.
if (message.role !== "user") return null;
return (
<div className="absolute -top-2 right-0 z-20">
<ActionTrigger
open={menuOpen}
onToggle={() => setMenuOpen((v) => !v)}
onOpen={() => setMenuOpen(true)}
/>
{menuOpen ? (
<ActionMenu
persisted={Boolean(message.sessionId)}
onDismiss={() => setMenuOpen(false)}
onEdit={() => openDialog("edit")}
onDelete={() => openDialog("delete")}
/>
) : null}
{dialog === "edit" ? <EditMessageDialog message={message} onClose={closeDialog} /> : null}
{dialog === "delete" ? <DeleteMessageDialog message={message} onClose={closeDialog} /> : null}
</div>
);
}
+4
View File
@@ -7,6 +7,7 @@
* auth.ts /auth/login, /health
* chat.ts /chat (sync + streaming)
* sessions.ts /sessions (list/create/delete, message history)
* messages.ts /messages/{id} (PATCH edit / DELETE)
* personas.ts /personas
* admin.ts /admin/* CRUD (personas, users, llm, rag, iot, automations…)
* adminChannels.ts /admin/channels/* (adapters, config, platform views, login)
@@ -41,6 +42,8 @@ export {
} from "./api/sessions";
export type { MessageDto } from "./api/sessions";
export { deleteMessage, patchMessage } from "./api/messages";
export { fetchPersonas } from "./api/personas";
export {
@@ -100,6 +103,7 @@ export {
adminBackthink,
adminListNotifications,
adminObservabilityAudit,
adminObservabilityLogs,
adminObservabilityMetrics,
adminObservabilityTrace,
adminSystemBackup,
+14
View File
@@ -1,4 +1,6 @@
import type {
AdminLogLine,
AdminLogQuery,
AdminNotification,
AdminNotificationQuery,
AdminObservabilityMetrics,
@@ -24,6 +26,18 @@ export function adminObservabilityTrace(limit?: number): Promise<AdminTraceSpan[
export function adminObservabilityAudit(limit?: number): Promise<AuditRecord[]> {
return request<AuditRecord[]>(`/admin/observability/audit${limitQuery(limit)}`);
}
/** GET /api/v1/admin/observability/logs?level=&limit= — the gateway's structured
* log tail, newest first (owner/admin only). An omitted `level` means “all”; the
* backend defaults `limit` to 200 and caps it at 1000. */
export function adminObservabilityLogs(
params: AdminLogQuery = {}
): Promise<AdminLogLine[]> {
const qs = new URLSearchParams();
if (params.level) qs.set("level", params.level);
if (params.limit !== undefined) qs.set("limit", String(params.limit));
const q = qs.toString();
return request<AdminLogLine[]>(`/admin/observability/logs${q ? `?${q}` : ""}`);
}
/** GET /api/v1/admin/backthink — persisted background-think records (newest first). */
export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
+36
View File
@@ -0,0 +1,36 @@
import type { MessageDto } from "./sessions";
import { request } from "./core";
/**
* Single-message mutations (design/16 §4.2 D3, Docs/api-client.md §6a/6b).
* Both succeed against the persisted row and broadcast the change over the WS
* hub (`message_edited` / `message_deleted`), so other clients converge without
* a re-fetch; the caller's REST response is only the acknowledgement.
*/
/** The gateway answers PATCH with `{data: <updated message DTO>}` (see
* gateway/history_write.go `writeJSON(w, 200, toHistoryMessage(msg))` and
* history_write_broadcast_test.go, which decodes `data` straight into the DTO).
* Docs/api-client.md §6a writes that envelope as `{data:{message}}`; the optional
* `message` field keeps this call working either way, so a plain DTO is never
* mistaken for an empty result (which would blank the bubble). */
type PatchMessageEnvelope = MessageDto & { message?: MessageDto };
/** PATCH /api/v1/messages/{id} — replace one message's `content`. The backend
* validates non-empty `≤4000` runes (400) and existence (404), and only ever
* changes `content`; role / timestamps / platform are preserved. */
export async function patchMessage(id: string, content: string): Promise<MessageDto> {
const res = await request<PatchMessageEnvelope>(
`/messages/${encodeURIComponent(id)}`,
{ method: "PATCH", body: JSON.stringify({ content }) }
);
return res.message ?? res;
}
/** DELETE /api/v1/messages/{id} — remove one message (404 when already gone).
* The response echoes the deleted id, so a caller can assert what it removed. */
export function deleteMessage(id: string): Promise<{ deleted: string }> {
return request<{ deleted: string }>(`/messages/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
+22
View File
@@ -401,6 +401,28 @@ export interface AdminLogRecord {
msg: string;
}
/** Severity filter of the live log tail (GET /api/v1/admin/observability/logs).
* An absent `level` means “all”. */
export type AdminLogLevel = "debug" | "info" | "warn" | "error";
/** One line of the gateway's structured log ring buffer (newest first).
* `time` is RFC3339 on current builds but the field is typed as either that or
* epoch milliseconds so the renderer stays compatible with both; `source` is
* the `module/subfeature` tag every log line carries (never blank). */
export interface AdminLogLine {
time: string | number;
level: string;
source: string;
msg: string;
}
/** Query for GET /api/v1/admin/observability/logs. `limit` defaults to 200 and
* is clamped to 1000 by the backend. */
export interface AdminLogQuery {
level?: AdminLogLevel;
limit?: number;
}
/** A knowledge-base document (GET /api/v1/admin/rag). */
export interface AdminRagDoc {
id: string;