feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)
agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)
实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
This commit is contained in:
1 parent
37e98ec584
commit
9c2a37c4ac
32 files changed
+1768
-136
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { adminListMemories, adminMemorySearch } from "../../lib/api";
|
||||
import { adminAddMemory, adminListMemories, adminMemorySearch } from "../../lib/api";
|
||||
import type { MemoryRecord, MemorySearchHit } from "../../lib/types";
|
||||
import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
@@ -9,10 +9,12 @@ import { Button, Surface, Tag } from "../../components/primitives";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
habitual: "ok",
|
||||
persona: "ok",
|
||||
project: "warn",
|
||||
};
|
||||
|
||||
const SCOPES = ["user", "persona", "project"] as const;
|
||||
|
||||
function MemoryTable({ rows, loading, error }: { rows: MemoryRecord[] | null; loading: boolean; error: string | null }): React.ReactNode {
|
||||
const columns: AdminColumn<MemoryRecord>[] = [
|
||||
{ key: "scope", title: "作用域", render: (m) => <Tag tone={scopeTone[m.scope] ?? "neutral"}>{m.scope}</Tag> },
|
||||
@@ -20,7 +22,7 @@ function MemoryTable({ rows, loading, error }: { rows: MemoryRecord[] | null; lo
|
||||
{ key: "id", title: "ID", render: (m) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{m.id}</span> },
|
||||
];
|
||||
return (
|
||||
<AdminTable columns={columns} rows={rows} loading={loading} error={error} emptyTitle="无记忆数据" rowKey={(m) => m.id} />
|
||||
<AdminTable columns={columns} rows={rows} loading={loading} error={error} emptyTitle="当前无记忆:说出『记住…』会写入,或点『添加记忆』手动写入" rowKey={(m) => m.id} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +35,10 @@ export function MemoriesModule(): React.ReactNode {
|
||||
const [hits, setHits] = useState<MemorySearchHit[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
// Manual "添加记忆" form fields.
|
||||
const [addPersona, setAddPersona] = useState("cyrene");
|
||||
const [addScope, setAddScope] = useState<(typeof SCOPES)[number]>("persona");
|
||||
const [addContent, setAddContent] = useState("");
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const filtered = (data ?? []).filter((m) => {
|
||||
@@ -60,11 +66,36 @@ export function MemoriesModule(): React.ReactNode {
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const addMemory = () => {
|
||||
if (!addContent.trim()) {
|
||||
setFail("记忆内容不能为空");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminAddMemory({
|
||||
personaId: addPersona.trim() || "cyrene",
|
||||
scope: addScope,
|
||||
content: addContent.trim(),
|
||||
})
|
||||
.then(() => {
|
||||
log("memory.add", "ok");
|
||||
setAddContent("");
|
||||
// Reflect the new record immediately (also refreshes the table).
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("memory.add", "error");
|
||||
setFail(e instanceof Error ? e.message : "添加记忆失败");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="记忆管理"
|
||||
subtitle="GET /api/v1/admin/memories + POST /memories/search · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
|
||||
subtitle="GET /api/v1/admin/memories + POST /memories + POST /memories/search · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
|
||||
actions={
|
||||
<Button variant="soft" disabled={busy} onClick={search}>
|
||||
向量测试
|
||||
@@ -74,6 +105,42 @@ export function MemoriesModule(): React.ReactNode {
|
||||
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">添加记忆(写入后端持久化)</h3>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<input
|
||||
value={addPersona}
|
||||
onChange={(e) => setAddPersona(e.target.value)}
|
||||
placeholder="personaId"
|
||||
className="w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<select
|
||||
value={addScope}
|
||||
onChange={(e) => setAddScope(e.target.value as (typeof SCOPES)[number])}
|
||||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
>
|
||||
{SCOPES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={addContent}
|
||||
onChange={(e) => setAddContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && addContent.trim()) void addMemory();
|
||||
}}
|
||||
placeholder="记忆内容(如:她喜欢喝冰美式)…"
|
||||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<Button variant="primary" disabled={busy} onClick={addMemory}>
|
||||
添加记忆
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">作用域为 persona 时与「记住…」同路径;owner 私聊写入安全(群聊/陌生人隔离在 loop 层)。</span>
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">语义/关键词检索测试(真实 retrieval)</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -117,7 +184,9 @@ export function MemoriesModule(): React.ReactNode {
|
||||
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
|
||||
</Surface>
|
||||
|
||||
<Notice>编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。</Notice>
|
||||
<Notice>
|
||||
编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 手动添加 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { adminDeleteRag, adminListRag, adminSearchRag } from "../../lib/api";
|
||||
import { adminDeleteRag, adminIndexRag, adminListRag, adminSearchRag } from "../../lib/api";
|
||||
import type { AdminRagDoc, AdminRagHit } from "../../lib/types";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { Notice, PageHeader } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
|
||||
const WRITE_M10 = "上传/分块/嵌入为后端 M10+ 提供;当前只读列文档与检索,删除接口在 M10 落地。";
|
||||
|
||||
export function RagModule(): React.ReactNode {
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const { data, loading, error, reload } = useAsyncData(() => adminListRag(), []);
|
||||
@@ -15,6 +13,11 @@ export function RagModule(): React.ReactNode {
|
||||
const [hits, setHits] = useState<AdminRagHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
// Manual "导入文档" form fields.
|
||||
const [docName, setDocName] = useState("");
|
||||
const [docDesc, setDocDesc] = useState("");
|
||||
const [docContent, setDocContent] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const docs: AdminRagDoc[] = data?.documents ?? [];
|
||||
|
||||
@@ -40,7 +43,34 @@ export function RagModule(): React.ReactNode {
|
||||
try {
|
||||
await adminDeleteRag(d.id);
|
||||
} catch (err: unknown) {
|
||||
setNotice(err instanceof Error ? err.message : WRITE_M10);
|
||||
setNotice(err instanceof Error ? err.message : "文档删除为后端 M10 写操作");
|
||||
}
|
||||
};
|
||||
|
||||
const importDoc = async () => {
|
||||
if (!docName.trim() || !docContent.trim()) {
|
||||
setNotice("文档名称与内容不能为空");
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
await adminIndexRag({
|
||||
name: docName.trim(),
|
||||
desc: docDesc.trim() || undefined,
|
||||
content: docContent.trim(),
|
||||
});
|
||||
log("rag.index", "ok");
|
||||
setDocName("");
|
||||
setDocDesc("");
|
||||
setDocContent("");
|
||||
reload();
|
||||
setNotice("文档已导入,可按知识库检索。");
|
||||
} catch (err: unknown) {
|
||||
log("rag.index", "error");
|
||||
setNotice(err instanceof Error ? err.message : "导入文档失败");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,21 +78,43 @@ export function RagModule(): React.ReactNode {
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="知识库 RAG"
|
||||
subtitle="GET /api/v1/admin/rag · 文档列表(分块/嵌入状态) + 检索测试"
|
||||
subtitle="GET /api/v1/admin/rag · 文档列表(分块/嵌入状态) + POST /rag 导入 + 检索测试"
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
log("rag.upload", "ok");
|
||||
setNotice(WRITE_M10);
|
||||
}}
|
||||
>
|
||||
上传文档
|
||||
<Button variant="soft" onClick={reload} disabled={importing}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{notice ? <Notice tone="warn">{notice}</Notice> : null}
|
||||
{notice ? <Notice tone={notice.includes("失败") || notice.includes("不能为空") ? "err" : "warn"}>{notice}</Notice> : null}
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">导入文档(写入项目索引,可分块、可检索)</h3>
|
||||
<input
|
||||
value={docName}
|
||||
onChange={(e) => setDocName(e.target.value)}
|
||||
placeholder="文档名称(如:内部知识库.md)…"
|
||||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<input
|
||||
value={docDesc}
|
||||
onChange={(e) => setDocDesc(e.target.value)}
|
||||
placeholder="描述(可选,作为项目描述)…"
|
||||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<textarea
|
||||
value={docContent}
|
||||
onChange={(e) => setDocContent(e.target.value)}
|
||||
placeholder="粘贴文档正文,空行分段,后端自动分块并落到项目索引(关键词/行上下文可检索)…"
|
||||
rows={6}
|
||||
className="w-full resize-y rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" disabled={importing} onClick={() => void importDoc()}>
|
||||
{importing ? "导入中…" : "导入文档"}
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -76,7 +128,7 @@ export function RagModule(): React.ReactNode {
|
||||
) : error ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">{error}</p>
|
||||
) : docs.length === 0 ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">暂无知识文档(项目索引为空)。</p>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">当前无文档:点『导入文档』导入资料后可按知识库检索。</p>
|
||||
) : (
|
||||
docs.map((d) => (
|
||||
<div
|
||||
@@ -151,7 +203,7 @@ export function RagModule(): React.ReactNode {
|
||||
|
||||
<Notice>
|
||||
分块/嵌入状态与语义检索:配置了 pgvector / SQLite 向量增强后,后端经真实语义/向量路径返回(非空壳);
|
||||
未配置时走项目索引关键词 + 行上下文。上传/删除为后端 M10 写操作。
|
||||
未配置时走项目索引关键词 + 行上下文(导入文档即时可查)。文档删除为后端 M10 写操作。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { adminListSessions, adminExportSession } from "../../lib/api";
|
||||
import type { SessionRecord } from "../../lib/types";
|
||||
import { adminListSessions, createSubSession } from "../../lib/api";
|
||||
import type { SessionMeta } from "../../lib/types";
|
||||
import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
|
||||
function exportBlob(sess: SessionRecord): void {
|
||||
function exportMeta(sess: SessionMeta): void {
|
||||
const blob = new Blob([JSON.stringify(sess, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `session-${sess.sessionId}.json`;
|
||||
a.download = `session-${sess.id}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -29,49 +29,45 @@ export function SessionsModule(): React.ReactNode {
|
||||
if (!query.trim()) return true;
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
s.platform.toLowerCase().includes(q) ||
|
||||
s.sessionId.toLowerCase().includes(q) ||
|
||||
s.channelId.toLowerCase().includes(q) ||
|
||||
s.actorRole.toLowerCase().includes(q)
|
||||
s.id.toLowerCase().includes(q) ||
|
||||
s.type.toLowerCase().includes(q) ||
|
||||
(s.title ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const exportSession = (s: SessionRecord) => {
|
||||
setBusy(s.sessionId);
|
||||
const newSubSession = () => {
|
||||
setBusy("new");
|
||||
setFail(null);
|
||||
adminExportSession(s.sessionId)
|
||||
.then(({ session }) => {
|
||||
log("session.export", "ok");
|
||||
exportBlob(session);
|
||||
createSubSession()
|
||||
.then(() => {
|
||||
log("session.create", "ok");
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("session.export", "error");
|
||||
setFail(e instanceof Error ? e.message : "导出失败");
|
||||
log("session.create", "error");
|
||||
setFail(e instanceof Error ? e.message : "新建次级会话失败");
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const columns: AdminColumn<SessionRecord>[] = [
|
||||
{ key: "platform", title: "渠道", render: (s) => <Tag tone="accent">{s.platform}</Tag> },
|
||||
{ key: "actor", title: "actor 元数据", render: (s) => <Tag tone="neutral">{s.actorRole}</Tag> },
|
||||
const columns: AdminColumn<SessionMeta>[] = [
|
||||
{ key: "type", title: "类型", render: (s) => <Tag tone={s.type === "main" ? "ok" : "accent"}>{s.type === "main" ? "主会话" : "次级会话"}</Tag> },
|
||||
{ key: "title", title: "标题", render: (s) => <span>{s.title ? s.title : <span className="text-[var(--mu-text-lo)]">(未命名)</span>}</span> },
|
||||
{
|
||||
key: "flags",
|
||||
title: "标记",
|
||||
render: (s) => (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{s.owner ? <Tag tone="ok">主人</Tag> : null}
|
||||
{s.isGroup ? <Tag tone="warn">群聊</Tag> : null}
|
||||
<Tag tone={s.memWritable ? "ok" : "neutral"}>{s.memWritable ? "记忆可写" : "记忆只读"}</Tag>
|
||||
</span>
|
||||
),
|
||||
key: "id",
|
||||
title: "会话 ID",
|
||||
render: (s) => <span className="font-mono text-xs">{s.id}</span>,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
title: "创建时间",
|
||||
render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span>,
|
||||
},
|
||||
{ key: "channel", title: "channelId", render: (s) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{s.channelId}</span> },
|
||||
{ key: "session", title: "sessionId", render: (s) => <span className="font-mono text-xs">{s.sessionId}</span> },
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: (s) => (
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => exportSession(s)}>
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => exportMeta(s)}>
|
||||
导出
|
||||
</Button>
|
||||
),
|
||||
@@ -82,7 +78,7 @@ export function SessionsModule(): React.ReactNode {
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="会话管理"
|
||||
subtitle="GET /api/v1/admin/sessions + GET /sessions/{id}/export · 渠道/模式/角色/分页 · 身份识别靠 channel 层 actor 元数据"
|
||||
subtitle="GET /api/v1/admin/sessions · 持久化主会话 + 次级会话(design/16 §2)· 主会话固定存在,次级会话独立保留上下文 · 身份识别靠 channel 层 actor 元数据"
|
||||
actions={
|
||||
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
|
||||
刷新
|
||||
@@ -93,23 +89,30 @@ export function SessionsModule(): React.ReactNode {
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="按渠道 / sessionId / channelId / actor 过滤…"
|
||||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="按会话 ID / 类型 / 标题过滤…"
|
||||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<Button variant="primary" disabled={busy !== null} onClick={newSubSession}>
|
||||
新建次级会话
|
||||
</Button>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={loading ? null : filtered}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无会话数据"
|
||||
rowKey={(s) => s.sessionId}
|
||||
emptyTitle="暂无会话:主会话 web 固定存在;用侧栏新建次级会话"
|
||||
rowKey={(s) => s.id}
|
||||
/>
|
||||
</Surface>
|
||||
|
||||
<Notice>导出当前为会话身份元数据 JSON(完整会话消息存储由后端 M10+ 提供)。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。</Notice>
|
||||
<Notice>
|
||||
列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,18 @@ function SourceLabel({ deviceId }: { deviceId?: string }): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
/** Small sender label for a third-party message (design/16 §4.6): shows the
|
||||
* sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat
|
||||
* (no senderName), so owner bubbles stay unlabelled. */
|
||||
function SenderLabel({ name }: { name?: string }): React.ReactNode {
|
||||
if (!name) return null;
|
||||
return (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||
第三方 · {name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
|
||||
* message's own `mode`: the same role renders differently under different modes,
|
||||
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
||||
@@ -308,6 +320,7 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
{body}
|
||||
</div>
|
||||
<SourceLabel deviceId={message.deviceId} />
|
||||
<SenderLabel name={message.senderName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,12 +21,15 @@ import type {
|
||||
LlmTestRequest,
|
||||
LlmTestResult,
|
||||
LoginResult,
|
||||
MemoryAddRequest,
|
||||
MemoryAddResult,
|
||||
MemoryRecord,
|
||||
MemorySearchRequest,
|
||||
MemorySearchResult,
|
||||
Mode,
|
||||
PersonaRecord,
|
||||
PersonaUpsertRequest,
|
||||
RagIndexRequest,
|
||||
Role,
|
||||
SessionMeta,
|
||||
SessionRecord,
|
||||
@@ -205,6 +208,8 @@ export interface MessageDto {
|
||||
platform?: string;
|
||||
/** Concrete client/device that produced the message (design/16 §4.5). */
|
||||
deviceId?: string;
|
||||
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
|
||||
senderName?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -221,6 +226,7 @@ function toChatMessage(m: MessageDto): ChatMessage {
|
||||
thinking: m.thinking,
|
||||
platform: m.platform,
|
||||
deviceId: m.deviceId,
|
||||
senderName: m.senderName,
|
||||
createdAt: m.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -295,9 +301,11 @@ export function isNotImplemented(err: unknown): boolean {
|
||||
export function adminListPersonas(): Promise<PersonaRecord[]> {
|
||||
return request<PersonaRecord[]>("/admin/personas");
|
||||
}
|
||||
/** GET /api/v1/admin/sessions */
|
||||
export function adminListSessions(): Promise<SessionRecord[]> {
|
||||
return request<SessionRecord[]>("/admin/sessions");
|
||||
/** GET /api/v1/admin/sessions — the persistent main + secondary sessions (design/16).
|
||||
* The main session (fixed id) is always present, so the panel is never empty in a
|
||||
* history-wired deployment. */
|
||||
export function adminListSessions(): Promise<SessionMeta[]> {
|
||||
return request<SessionMeta[]>("/admin/sessions");
|
||||
}
|
||||
/** GET /api/v1/admin/memories */
|
||||
export function adminListMemories(): Promise<MemoryRecord[]> {
|
||||
@@ -364,6 +372,14 @@ export function adminDeleteRag(id: string): Promise<{ id: string }> {
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/rag — imports one knowledge document (chunk + index), so it
|
||||
* becomes keyword/searchable in the project index. */
|
||||
export function adminIndexRag(req: RagIndexRequest): Promise<{ name: string }> {
|
||||
return request<{ name: string }>("/admin/rag", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Admin write endpoints (owner-only, audited; real store or honest 501). --- */
|
||||
|
||||
@@ -491,6 +507,13 @@ export function adminPluginUninstall(req: { name: string }): Promise<{ name: str
|
||||
export function adminMemorySearch(req: MemorySearchRequest): Promise<MemorySearchResult> {
|
||||
return request<MemorySearchResult>("/admin/memories/search", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/memories — writes one memory item (manual "添加记忆"). */
|
||||
export function adminAddMemory(req: MemoryAddRequest): Promise<MemoryAddResult> {
|
||||
return request<MemoryAddResult>("/admin/memories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** GET /api/v1/admin/sessions/{id}/export — exports a session as JSON. */
|
||||
export function adminExportSession(id: string): Promise<{ session: SessionRecord }> {
|
||||
return request<{ session: SessionRecord }>(`/admin/sessions/${encodeURIComponent(id)}/export`);
|
||||
|
||||
@@ -88,6 +88,10 @@ export interface ChatMessage {
|
||||
* defaults to labeling the bubble source when this is present; absent on
|
||||
* legacy/client-optimistic messages kept without it. */
|
||||
deviceId?: string;
|
||||
/** Human-readable sender display name of a third-party message (group/stranger,
|
||||
* design/16 §4.6). Blank for the owner's private chat, so present only when a
|
||||
* non-owner is speaking; the recipient renders it as the sender label. */
|
||||
senderName?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -494,3 +498,25 @@ export interface MemorySearchResult {
|
||||
semantic: boolean;
|
||||
hits: MemorySearchHit[];
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/memories body — the manual "添加记忆" write. Scope is
|
||||
* optional and defaults to persona; it must be one of user|persona|project. */
|
||||
export interface MemoryAddRequest {
|
||||
personaId?: string;
|
||||
content: string;
|
||||
scope?: "user" | "persona" | "project";
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/memories result (the stored item's identity + text). */
|
||||
export interface MemoryAddResult {
|
||||
id: string;
|
||||
scope: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/rag body — the admin "导入文档" write. */
|
||||
export interface RagIndexRequest {
|
||||
name: string;
|
||||
desc?: string;
|
||||
content: string;
|
||||
}
|
||||
@@ -176,6 +176,7 @@ function parsePersistedMessage(v: unknown): ChatMessage | null {
|
||||
image: typeof r.image === "string" ? r.image : undefined,
|
||||
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
|
||||
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
|
||||
senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined,
|
||||
createdAt: typeof r.createdAt === "number" ? r.createdAt : now(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user