feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
- 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:
1 parent
025dd1796b
commit
1ac126fc9e
9 files changed
+611
-12
No files matched your search
+2
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user