diff --git a/Docs/api-client.md b/Docs/api-client.md index 925dd21..f1d607b 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -42,6 +42,9 @@ | 9 | `/api/v1/admin/llm` | GET/PUT | 🕐 | LLM 模型/路由/降级(M10) | | 10 | `/api/v1/admin/automations` | GET/POST | 🕐 | 自动化与提醒(M10) | +> **M10 前端契约补充(管理面板,均🕐未实现,前端按契约封装、后端 404 时显示"待实现"壳,不报错)**: +> `GET /api/v1/admin/users`(用户/角色/API key)· `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/logs` · `GET /api/v1/admin/trace` · `GET /api/v1/admin/audit` · `GET /api/v1/admin/system`。写操作(策略/档案/密钥/系统设置)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。 + ## 3. WebSocket `/ws/chat`(实时) - **握手**: `ws://:8080/ws/chat?token=` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 @@ -57,5 +60,6 @@ - **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。 ## 5. 变更记录 +- `v0.3`(M10):管理面板 `/admin` 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only `plugins/policy` 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。 - `v0.2`(M8):gateway 落地——登录/health/chat/sessions/personas + admin 只读端点 + `plugins/policy` 写 + WS `/ws/chat`(JWT + bus 广播)。 - `v0.1`(契约先行):骨架 + 端点登记表。 diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index cdf606f..2013642 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -1,7 +1,9 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { TopBar } from "./components/TopBar"; import { Sidebar, BottomNav } from "./components/Nav"; import { ModeRouter } from "./modes/ModeRouter"; +import { AdminApp } from "./admin/AdminApp"; +import { parseAdminHash } from "./admin/router"; import { connectWs, disconnectWs } from "./lib/ws"; import { initTheme } from "./lib/theme"; import { getAuthToken } from "./lib/api"; @@ -9,12 +11,24 @@ import { getAuthToken } from "./lib/api"; export default function App(): React.ReactNode { // Apply the persisted (dark-first) theme and open the realtime channel only // when we hold a token — otherwise the gateway rejects the handshake with 401. + const [route, setRoute] = useState(() => parseAdminHash(window.location.hash)); useEffect(() => { initTheme(); if (getAuthToken()) connectWs(); - return () => disconnectWs(); + const onHash = () => setRoute(parseAdminHash(window.location.hash)); + window.addEventListener("hashchange", onHash); + return () => { + disconnectWs(); + window.removeEventListener("hashchange", onHash); + }; }, []); + // The M9 modal UI and the admin subsystem (design/10) are two separate shells. + // `/admin` is reached via `#/admin`; every admin module is a hash segment. + if (route.admin) { + return ; + } + return (
diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx new file mode 100644 index 0000000..91eff12 --- /dev/null +++ b/frontend/web/src/admin/AdminApp.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { applyTheme, readTheme, type ThemeMode } from "../lib/theme"; +import { getAuthToken, getStoredRole } from "../lib/api"; +import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections"; +import { navigateAdmin, type AdminSectionId } from "./router"; +import { Button, Tag } from "../components/primitives"; +import { DashboardModule } from "./modules/Dashboard"; +import { PersonasModule } from "./modules/Personas"; +import { SessionsModule } from "./modules/Sessions"; +import { MemoriesModule } from "./modules/Memories"; +import { PluginsModule } from "./modules/Plugins"; +import { ChannelsModule } from "./modules/Channels"; +import { IoTModule } from "./modules/IoT"; +import { RagModule } from "./modules/Rag"; +import { LlmModule } from "./modules/Llm"; +import { AutomationsModule } from "./modules/Automations"; +import { AgentsModule } from "./modules/Agents"; +import { BackthinkModule } from "./modules/Backthink"; +import { ObservabilityModule } from "./modules/Observability"; +import { UsersModule } from "./modules/Users"; +import { SystemModule } from "./modules/System"; + +function renderSection(section: AdminSectionId): React.ReactNode { + switch (section) { + case "dashboard": return ; + case "personas": return ; + case "sessions": return ; + case "memories": return ; + case "plugins": return ; + case "channels": return ; + case "iot": return ; + case "rag": return ; + case "llm": return ; + case "automations": return ; + case "agents": return ; + case "backthink": return ; + case "observability": return ; + case "users": return ; + case "system": return ; + default: return ; + } +} + +function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode { + return ( + + ); +} + +function AdminTopBar({ section }: { section: AdminSectionId }): React.ReactNode { + const role = getStoredRole(); + const [theme, setTheme] = useState(readTheme); + const meta = ADMIN_SECTIONS.find((s) => s.id === section); + const toggleTheme = () => { + const next: ThemeMode = theme === "dark" ? "light" : "dark"; + applyTheme(next); + setTheme(next); + }; + + return ( +
+
+ Cyrene +
+
Cyrene · 管理面板
+
{meta?.label ?? "仪表盘"}
+
+
+
+ {role ? {role} : null} + + +
+ ); +} + +function LoginGate(): React.ReactNode { + return ( +
+
🔒
+
需要登录
+

+ 管理面板需持有 JWT。请先到用户端 设置 → 连接 登录获取令牌后,再返回本页。 +

+ +
+ ); +} + +function Forbidden403(): React.ReactNode { + const role = getStoredRole(); + return ( +
+
🚫
+
403 · 权限不足
+

+ 当前角色 {role ?? "?"}(viewer 只读)。管理面板需 owner 或 admin 角色;管理端写操作仅 owner。 +

+
+ +
+
+ ); +} + +export function AdminApp({ section }: { section: AdminSectionId }): React.ReactNode { + // RBAC guard (design/10 §2 + design/11 §7). A logged-in viewer or an + // unauthenticated user never sees the admin module; the gateway would also + // reject them with 401/403. + if (!getAuthToken() || !getStoredRole()) { + return ( +
+ + +
+ ); + } + if (getStoredRole() === "viewer") { + return ( +
+ + +
+ ); + } + + return ( +
+ +
+ +
+
{renderSection(section)}
+
+
+
+ ); +} diff --git a/frontend/web/src/admin/audit.ts b/frontend/web/src/admin/audit.ts new file mode 100644 index 0000000..718f4dd --- /dev/null +++ b/frontend/web/src/admin/audit.ts @@ -0,0 +1,35 @@ +import { create } from "zustand"; +import type { AuditRecord } from "../lib/types"; +import { getStoredRole } from "../lib/api"; +import { now, uid } from "../lib/util"; + +/** + * Client-side audit trail. Every admin write the panel performs is pushed here + * with who/what/when/result so the 可观测性 → 审计 tab has real content even + * before the backend exposes an audit endpoint. It is also the visual front of + * the "主代理代审 + 审计日志" red line. + */ + +interface AuditStore { + records: AuditRecord[]; + log: (what: string, result: "ok" | "error") => void; + clear: () => void; +} + +export const useAuditStore = create((set) => ({ + records: [], + log: (what, result) => + set((s) => ({ + records: [ + { + id: uid(), + who: getStoredRole() ?? "anonymous", + what, + when: new Date(now()).toISOString(), + result, + }, + ...s.records, + ].slice(0, 200), + })), + clear: () => set({ records: [] }), +})); diff --git a/frontend/web/src/admin/components.tsx b/frontend/web/src/admin/components.tsx new file mode 100644 index 0000000..6c4fdcc --- /dev/null +++ b/frontend/web/src/admin/components.tsx @@ -0,0 +1,144 @@ +import type { ReactNode } from "react"; +import { EmptyState, Tag, type TagTone } from "../components/primitives"; + +export interface PageHeaderProps { + title: string; + subtitle?: string; + actions?: ReactNode; +} + +export function PageHeader({ title, subtitle, actions }: PageHeaderProps): ReactNode { + return ( +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} + +export interface NoticeProps { + tone?: TagTone; + children: ReactNode; +} + +/** A soft inline notice — used for "只读/后端 M10+ 提供" hints. */ +export function Notice({ tone = "neutral", children }: NoticeProps): ReactNode { + return ( +
+ {tone === "warn" ? "待实现" : tone === "err" ? "错误" : "提示"} + {children} +
+ ); +} + +export interface StatCardProps { + icon?: ReactNode; + label: string; + value: ReactNode; + sub?: ReactNode; + tone?: TagTone; +} + +export function StatCard({ icon, label, value, sub, tone = "accent" }: StatCardProps): ReactNode { + return ( +
+
+ {icon ? {icon} : null} + {label} +
+
{value}
+ {sub ?
{sub}
: null} + {tone} +
+ ); +} + +export function CardGrid({ children }: { children: ReactNode }): ReactNode { + return
{children}
; +} + +export interface AdminColumn { + key: string; + title: string; + render: (row: T) => ReactNode; + className?: string; +} + +export interface AdminTableProps { + columns: AdminColumn[]; + rows: readonly T[] | null; + loading: boolean; + error: string | null; + emptyTitle?: string; + rowKey: (row: T) => string; +} + +/** A strict, generic data table. Loading/error/empty states are handled here so + * every module has a complete surface without duplicating state rendering. */ +export function AdminTable({ + columns, + rows, + loading, + error, + emptyTitle = "暂无数据", + rowKey, +}: AdminTableProps): ReactNode { + if (loading) { + return
加载中…
; + } + if (error) { + return
{error}
; + } + if (!rows || rows.length === 0) { + return ; + } + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
+ {c.title} +
+ {c.render(row)} +
+
+ ); +} + +export interface KeyValueRowProps { + label: string; + value: ReactNode; + mono?: boolean; +} + +export function KeyValueRow({ label, value, mono = false }: KeyValueRowProps): ReactNode { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/frontend/web/src/admin/hooks.ts b/frontend/web/src/admin/hooks.ts new file mode 100644 index 0000000..3827af3 --- /dev/null +++ b/frontend/web/src/admin/hooks.ts @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +/** Re-run `reload` on a fixed interval (ms). Pass null to disable. Backend data + * tables refresh this way; WS-backed surfaces use the app store instead. */ +export function useAutoReload(reload: () => void, intervalMs: number | null): void { + useEffect(() => { + if (intervalMs === null) return; + const id = window.setInterval(reload, intervalMs); + return () => window.clearInterval(id); + }, [reload, intervalMs]); +} diff --git a/frontend/web/src/admin/modules/Agents.tsx b/frontend/web/src/admin/modules/Agents.tsx new file mode 100644 index 0000000..863288c --- /dev/null +++ b/frontend/web/src/admin/modules/Agents.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { useAppStore } from "../../store/useAppStore"; +import type { AgentInfo, AgentStatus } from "../../lib/types"; +import { Notice, PageHeader } from "../components"; +import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives"; +import { sendUp } from "../../lib/ws"; +import { useAuditStore } from "../audit"; + +const statusTone: Record = { + idle: { label: "空闲", tone: "neutral" }, + running: { label: "运行中", tone: "accent" }, + blocked: { label: "阻塞", tone: "warn" }, + done: { label: "完成", tone: "ok" }, + error: { label: "错误", tone: "err" }, +}; + +function AgentCard({ agent, onKill }: { agent: AgentInfo; onKill: (id: string) => void }): React.ReactNode { + const meta = statusTone[agent.status] ?? statusTone.idle; + return ( + +
+ + {agent.name} + {agent.role ? {agent.role} : null} + {meta.label} +
+ {typeof agent.progress === "number" ? ( +
+
+
+ ) : null} + {agent.thinking ? ( +
{agent.thinking}
+ ) : null} + {agent.tools?.map((t, i) => ( +
⚙ {t.name}{t.ok === false ? " ✗" : ""}
+ ))} + {agent.message ?
{agent.message}
: null} +
+ + +
+ + ); +} + +export function AgentsModule(): React.ReactNode { + const agents = useAppStore((s) => s.agents); + const flowGraph = useAppStore((s) => s.flowGraph); + const taskLogs = useAppStore((s) => s.taskLogs); + const clearTaskLog = useAppStore((s) => s.clearTaskLog); + const log = useAuditStore((s) => s.log); + const [tab, setTab] = useState<"main" | "sub">("main"); + + const subAgents = agents.filter((a) => a.id !== "main"); + const list = tab === "main" ? agents.filter((a) => a.id === "main") : subAgents; + + return ( +
+ { + log("agent.respawn", "ok"); + alert("重派(占位):后端 M10+ 提供子代理重派。"); + }} + > + 重派 + + } + /> + +
+ setTab("main")}>主会话 + setTab("sub")}>子代理 ({subAgents.length}) +
+ + {flowGraph ? ( + + {flowGraph.nodes.map((n, i) => ( +
+ {n.label} + {i < flowGraph.nodes.length - 1 ? → : null} +
+ ))} +
+ ) : ( + 流程图占位 —— 连接 /ws/chat 后按 graph_update 事件渲染。 + )} + + {list.length === 0 ? ( + + ) : ( +
+ {list.map((a) => ( + { log(`agent.kill:${id}`, "ok"); alert(`终止 ${id}(占位)`); }} /> + ))} +
+ )} + + +

Agent 消息流 / 后台任务日志

+ {Object.keys(taskLogs).length === 0 ? ( +
暂无任务日志。
+ ) : ( + Object.entries(taskLogs).map(([id, text]) => ( +
+
+ {id} + +
+
{text}
+
+ )) + )} + 子代理链上下文查询、终止/重派、流程图导出需后端 M10+ 提供。 +
+
+ ); +} + +function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode { + return ( + + ); +} diff --git a/frontend/web/src/admin/modules/Automations.tsx b/frontend/web/src/admin/modules/Automations.tsx new file mode 100644 index 0000000..901ba5e --- /dev/null +++ b/frontend/web/src/admin/modules/Automations.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListAutomations, isNotImplemented } from "../../lib/api"; +import { useAuditStore } from "../audit"; +import { Notice, PageHeader } from "../components"; +import { Button, Surface, Switch, Tag } from "../../components/primitives"; + +interface ReminderRow { + id: string; + name: string; + kind: string; + cron: string; + enabled: boolean; +} + +const sample: ReminderRow[] = [ + { id: "r1", name: "睡前总结", kind: "reminder", cron: "0 22 * * *", enabled: true }, + { id: "r2", name: "晨间问候", kind: "cron", cron: "0 8 * * *", enabled: true }, + { id: "r3", name: "设备待机提醒", kind: "queue", cron: "—", enabled: false }, +]; + +export function AutomationsModule(): React.ReactNode { + const { data, error } = useAsyncData(adminListAutomations, []); + const log = useAuditStore((s) => s.log); + const [rows, setRows] = useState(sample); + const backend = !isNotImplemented(error); + + return ( +
+ { + log("automation.create", "ok"); + alert("新增自动化(占位):后端 M10+ 提供 CRUD。"); + }} + > + 新增自动化 + + } + /> + + {backend && !error ? 后端已返回自动化配置(数据以接口为准)。 : null} + {error && !backend ? 后端未提供 /api/v1/admin/automations,以下为示例壳(M10 提供真实数据)。 : null} + {error && backend ? {error} : null} + + +

提醒 / CRON / 队列事件

+ {rows.map((r) => ( +
+ {r.name} + {r.kind} + {r.cron} + + setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" /> + + +
+ ))} +
+ + +

后台任务 / 运行历史

+
+ {["消息队列事件", "后台任务(含终端任务)", "运行日志"].map((x) => ( +
+
📦
+ {x} 占位 —— 后端 M10+ 提供 +
+ ))} +
+
+
+ ); +} diff --git a/frontend/web/src/admin/modules/Backthink.tsx b/frontend/web/src/admin/modules/Backthink.tsx new file mode 100644 index 0000000..495726f --- /dev/null +++ b/frontend/web/src/admin/modules/Backthink.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { useAppStore } from "../../store/useAppStore"; +import { useAuditStore } from "../audit"; +import { Notice, PageHeader } from "../components"; +import { Button, EmptyState, Surface, Tag } from "../../components/primitives"; +import type { BackthinkItem } from "../../lib/types"; + +function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode { + const markPushed = useAppStore((s) => s.markBackthinkPushed); + const [open, setOpen] = useState(false); + + return ( + +
+ {new Date(item.at).toLocaleTimeString()} + {item.trigger} + {item.shouldNotify ? "需要打扰" : "静默"} + {item.pushed ? "已推送" : "未推送"} + + + +
+ {item.conclusion ? ( +
+ {item.conclusion} +
+ ) : null} + + {open ? ( +
+ 链路导图占位 —— 后端 M10+ 提供思维链 / 流程图(graph_update 事件已接入实时通道)。 +
+ ) : null} +
+ ); +} + +export function BackthinkModule(): React.ReactNode { + const backthink = useAppStore((s) => s.backthink); + const log = useAuditStore((s) => s.log); + const [filter, setFilter] = useState<"all" | "notify" | "silent">("all"); + + const items = backthink.filter((b) => { + if (filter === "notify") return b.shouldNotify === true; + if (filter === "silent") return b.shouldNotify !== true; + return true; + }); + + return ( +
+ { + log("backthink.intervene", "ok"); + alert("干预(占位):后端 M10+ 提供打断/重派。"); + }} + > + 干预 + + } + /> + +
+ setFilter("all")}>全部 + setFilter("notify")}>需打扰 + setFilter("silent")}>静默 +
+ + {items.length === 0 ? ( + + ) : ( +
+ {items.map((b) => ( + + ))} +
+ )} + + 数据源:/ws/chat 下行 backthink / thinking 事件(实时);导图与干预为后端 M10+ 提供。 +
+ ); +} + +function FilterPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode { + return ( + + ); +} diff --git a/frontend/web/src/admin/modules/Channels.tsx b/frontend/web/src/admin/modules/Channels.tsx new file mode 100644 index 0000000..c9383a3 --- /dev/null +++ b/frontend/web/src/admin/modules/Channels.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListChannels } from "../../lib/api"; +import type { AdminChannelRecord } 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"; + +const columns: AdminColumn[] = [ + { key: "name", title: "渠道", render: (c) => {c.name} }, + { + key: "connected", + title: "状态", + render: (c) => ( + + + {c.connected ? "已连接" : "未连接"} + + ), + }, +]; + +export function ChannelsModule(): React.ReactNode { + const { data, loading, error, reload } = useAsyncData(adminListChannels, []); + useAutoReload(reload, 10000); + const log = useAuditStore((s) => s.log); + const [ownerId, setOwnerId] = useState(""); + const [group, setGroup] = useState(false); + const [verdict, setVerdict] = useState(null); + + return ( +
+ { + log("channel.reconnect", "ok"); + alert("热重载/重连(占位):后端 M10+ 提供。"); + }} + > + 热重载 + + } + /> + + + c.name} + /> + + + +

身份识别测试(占位 · 策略引擎 M10 接入)

+

+ 输入渠道确认的发送者账号 id,模拟 channel 层 actor 元数据 → 策略引擎判定;判定绝不依赖提示词 / LLM 自我判断。 +

+
+ setOwnerId(e.target.value)} + placeholder="渠道账号 id(如 weixin-ilink 的 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)]" + /> + + +
+ {verdict ?
判定:{verdict}
: null} +
+ + 账号配置(扫码/token)、消息流预览、推送测试为后端 M10+ 提供;此页当前展示渠道注册与连接健康。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/Dashboard.tsx b/frontend/web/src/admin/modules/Dashboard.tsx new file mode 100644 index 0000000..4f962a7 --- /dev/null +++ b/frontend/web/src/admin/modules/Dashboard.tsx @@ -0,0 +1,114 @@ +import { useAsyncData } from "../../lib/useAsyncData"; +import { + adminListChannels, + adminListIoT, + adminListMemories, + adminListPersonas, + adminListPlugins, + adminListSessions, +} from "../../lib/api"; +import { useAppStore } from "../../store/useAppStore"; +import { navigateAdmin, type AdminSectionId } from "../router"; +import { CardGrid, StatCard } from "../components"; +import { Surface, Tag } from "../../components/primitives"; +import { useAutoReload } from "../hooks"; + +interface DashCard { + id: AdminSectionId; + label: string; + icon: string; + value: number | string; + sub?: string; + tone: "ok" | "warn" | "accent" | "neutral" | "err"; +} + +export function DashboardModule(): React.ReactNode { + const personas = useAsyncData(adminListPersonas, []); + const sessions = useAsyncData(adminListSessions, []); + const memories = useAsyncData(adminListMemories, []); + const plugins = useAsyncData(adminListPlugins, []); + const channels = useAsyncData(adminListChannels, []); + const iot = useAsyncData(adminListIoT, []); + useAutoReload(personas.reload, 10000); + useAutoReload(sessions.reload, 10000); + useAutoReload(memories.reload, 20000); + useAutoReload(plugins.reload, 15000); + useAutoReload(channels.reload, 10000); + useAutoReload(iot.reload, 8000); + + // WS-backed live signals (design/10 §3): subagents + backthink. + const agents = useAppStore((s) => s.agents); + const backthink = useAppStore((s) => s.backthink); + const wsStatus = useAppStore((s) => s.wsStatus); + + const nPersonas = personas.data?.length ?? 0; + const nSessions = sessions.data?.length ?? 0; + const nMemories = memories.data?.length ?? 0; + const nPlugins = plugins.data?.length ?? 0; + const nChannels = channels.data?.length ?? 0; + const nDevices = iot.data?.length ?? 0; + const offlineChannels = (channels.data ?? []).filter((c) => !c.connected).length; + const onlineDevices = (iot.data ?? []).filter((d) => d.online).length; + const runningAgents = agents.filter((a) => a.status === "running").length; + + const cards: DashCard[] = [ + { id: "personas", label: "人格", icon: "🧬", value: nPersonas, sub: "启停/默认/路由", tone: "accent" }, + { id: "sessions", label: "活跃会话", icon: "💬", value: nSessions, sub: "渠道 × actor", tone: "ok" }, + { id: "memories", label: "记忆", icon: "🧠", value: nMemories, sub: "三类作用域隔离", tone: "accent" }, + { id: "plugins", label: "插件与能力", icon: "🔌", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" }, + { id: "channels", label: "渠道", icon: "📡", value: `${nChannels - offlineChannels}/${nChannels}`, + sub: offlineChannels > 0 ? `${offlineChannels} 离线` : "全部在线", tone: offlineChannels > 0 ? "warn" : "ok" }, + { id: "iot", label: "IoT 设备", icon: "📲", value: `${onlineDevices}/${nDevices}`, + sub: onlineDevices === nDevices && nDevices > 0 ? "全部在线" : "设备状态", tone: nDevices > 0 && onlineDevices === nDevices ? "ok" : "neutral" }, + { id: "agents", label: "Agent 运行", icon: "🧩", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" }, + { id: "backthink", label: "后台思考活跃度", icon: "🌙", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" }, + ]; + + return ( +
+
+
+

仪表盘

+

多而全的概览 · 最详细信息在各独立页面

+
+
+ + + 实时通道 · {wsStatus} + +
+
+ + + {cards.map((c) => ( + + ))} + + + +
+

资源 / LLM / 业务概览

+ 占位(后端 M10+ 提供) +
+
+ + + +
+
卡片即入口,点击上溯卡片下钻到对应模块。
+
+
+ ); +} + +function MiniMetric({ label, value, hint }: { label: string; value: string; hint: string }): React.ReactNode { + return ( +
+
{label}
+
{value}
+
{hint}
+
+ ); +} diff --git a/frontend/web/src/admin/modules/IoT.tsx b/frontend/web/src/admin/modules/IoT.tsx new file mode 100644 index 0000000..5b1eb76 --- /dev/null +++ b/frontend/web/src/admin/modules/IoT.tsx @@ -0,0 +1,84 @@ +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListIoT } from "../../lib/api"; +import type { AdminIoTDevice } 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"; + +const columns: AdminColumn[] = [ + { key: "name", title: "设备", render: (d) => {d.name} }, + { key: "type", title: "类型", render: (d) => {d.type} }, + { key: "id", title: "ID", render: (d) => {d.id} }, + { + key: "online", + title: "状态", + render: (d) => ( + + + {d.online ? "在线" : "离线"} + + ), + }, + { + key: "state", + title: "实时状态", + render: (d) => ( + + {Object.entries(d.state) + .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`) + .join(" · ") || "—"} + + ), + }, +]; + +export function IoTModule(): React.ReactNode { + const { data, loading, error, reload } = useAsyncData(adminListIoT, []); + useAutoReload(reload, 8000); + const log = useAuditStore((s) => s.log); + + return ( +
+ + + + + } + /> + + + d.id} + /> + + + 实时上/下线经 WS device_update 事件或轮询刷新;控制/场景/规则写入需后端 M10+ 提供。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx new file mode 100644 index 0000000..d537944 --- /dev/null +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminGetLlm, isNotImplemented } from "../../lib/api"; +import { useAuditStore } from "../audit"; +import { Notice, PageHeader } from "../components"; +import { Button, Surface, Switch, Tag } from "../../components/primitives"; + +interface ProviderRow { + id: string; + name: string; + baseUrl: string; + model: string; + multimodal: boolean; + enabled: boolean; +} + +const sample: ProviderRow[] = [ + { id: "p1", name: "主模型", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true }, + { id: "p2", name: "备选模型", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true }, +]; + +export function LlmModule(): React.ReactNode { + const { data, error } = useAsyncData(adminGetLlm, []); + const log = useAuditStore((s) => s.log); + const [rows, setRows] = useState(sample); + const backend = !isNotImplemented(error); + + return ( +
+ { + log("llm.addProvider", "ok"); + alert("新增模型(占位):后端 M10+ 提供真实持久化。"); + }} + > + 新增模型 + + } + /> + + {backend && !error ? 后端已返回 LLM 配置(数据以接口为准)。 : null} + {error && !backend ? 后端未提供 /api/v1/admin/llm,以下为示例壳(M10 提供真实数据)。 : null} + {error && backend ? {error} : null} + + +

供应商 / 模型

+ {rows.map((r) => ( +
+ {r.name} + {r.model} + {r.baseUrl} + + setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" /> + setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, multimodal: v } : x)))} label="多模态" /> + + +
+ ))} +
+ + +

按角色路由 + 降级链

+ {["owner", "admin", "viewer"].map((role) => ( +
+ {role} + 主 → 备 → 本地 + 接口报错自动降级 +
+ ))} +
+ + 配置写入 / 多模态检测 / 用量费用统计为后端 M10+ 提供;per-role 路由 + 降级逻辑由后端 loop 实现。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/Memories.tsx b/frontend/web/src/admin/modules/Memories.tsx new file mode 100644 index 0000000..eada8e4 --- /dev/null +++ b/frontend/web/src/admin/modules/Memories.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListMemories } from "../../lib/api"; +import type { MemoryRecord } 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"; + +const scopeTone: Record = { + user: "accent", + habitual: "ok", + project: "warn", +}; + +const columns: AdminColumn[] = [ + { key: "scope", title: "作用域", render: (m) => {m.scope} }, + { key: "text", title: "内容", render: (m) => {m.text} }, + { key: "id", title: "ID", render: (m) => {m.id} }, +]; + +export function MemoriesModule(): React.ReactNode { + const { data, loading, error, reload } = useAsyncData(adminListMemories, []); + useAutoReload(reload, 15000); + const [query, setQuery] = useState(""); + const log = useAuditStore((s) => s.log); + + const filtered = (data ?? []).filter((m) => { + if (!query.trim()) return true; + const q = query.toLowerCase(); + return m.text.toLowerCase().includes(q) || m.scope.toLowerCase().includes(q) || m.id.toLowerCase().includes(q); + }); + + return ( +
+ { + log("memory.vectorTest", "ok"); + alert("向量检索测试(占位):后端 M10+ 提供语义/向量路径。"); + }} + > + 向量测试 + + } + /> + + + setQuery(e.target.value)} + placeholder="按内容 / 作用域 / ID 检索…" + 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)]" + /> + m.id} + /> + + + 编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/Observability.tsx b/frontend/web/src/admin/modules/Observability.tsx new file mode 100644 index 0000000..549019f --- /dev/null +++ b/frontend/web/src/admin/modules/Observability.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { useAppStore } from "../../store/useAppStore"; +import { useAuditStore } from "../audit"; +import { CardGrid, Notice, PageHeader, StatCard } from "../components"; +import { Button, Surface, Tag } from "../../components/primitives"; + +type Tab = "logs" | "trace" | "metrics" | "audit"; + +const TABS: ReadonlyArray<{ id: Tab; label: string }> = [ + { id: "logs", label: "实时日志" }, + { id: "trace", label: "Tracing" }, + { id: "metrics", label: "指标" }, + { id: "audit", label: "审计" }, +]; + +export function ObservabilityModule(): React.ReactNode { + const [tab, setTab] = useState("logs"); + const deviceUpdates = useAppStore((s) => s.deviceUpdates); + const audit = useAuditStore((s) => s.records); + const clearAudit = useAuditStore((s) => s.clear); + + return ( +
+ + +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "logs" ? : null} + {tab === "trace" ? : null} + {tab === "metrics" ? : null} + {tab === "audit" ? : null} +
+ ); +} + +function LogsTail({ lines }: { lines: string[] }): React.ReactNode { + return ( + +
+

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

+ tail +
+ {lines.length === 0 ? ( + 暂无实时事件。接入 /ws/chat 后,此处回显 stream / tool / device_update / reminder 等事件。 + ) : ( +
+          {lines.join("\n")}
+        
+ )} + 完整后端日志分级 tail / 过滤 / 导出需后端 M10+ 提供。 +
+ ); +} + +function TraceView(): React.ReactNode { + return ( + +

Tracing 视图

+
+ {["LLM 调用链", "工具链", "编排链"].map((x) => ( +
+
{x}
+ {x} 占位 —— 后端 M10+ 提供 span/trace 数据 +
+ ))} +
+
+ ); +} + +function MetricsView(): React.ReactNode { + return ( +
+ + + + + + + 指标时序图 / 导出需后端 M10+ 提供。 +
+ ); +} + +interface AuditViewProps { + records: ReturnType["records"]; + onClear: () => void; +} + +function AuditView({ records, onClear }: AuditViewProps): React.ReactNode { + return ( + +
+

审计日志(who / what / when / result)

+ 管理 + 敏感操作全程落审计 + + + +
+ {records.length === 0 ? ( + 暂无审计记录。本面板的管理/敏感操作(策略写入、主人档案编辑、API key 签发等)都会在此登记。 + ) : ( + + + + + + + + + + + {records.map((r) => ( + + + + + + + ))} + +
whowhatwhenresult
{r.who}{r.what}{new Date(r.when).toLocaleString()}{r.result}
+ )} +
+ ); +} diff --git a/frontend/web/src/admin/modules/Personas.tsx b/frontend/web/src/admin/modules/Personas.tsx new file mode 100644 index 0000000..65e4f89 --- /dev/null +++ b/frontend/web/src/admin/modules/Personas.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListPersonas } from "../../lib/api"; +import type { PersonaRecord } 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"; + +const columns: AdminColumn[] = [ + { key: "name", title: "名称", render: (p) => {p.name} }, + { + key: "default", + title: "默认", + render: (p) => (p.isDefault ? 默认 : —), + }, + { key: "mode", title: "模式", render: (p) => {p.mode} }, + { key: "memory", title: "记忆域", render: (p) => {p.memoryNamespace} }, + { key: "id", title: "ID", render: (p) => {p.id} }, +]; + +export function PersonasModule(): React.ReactNode { + const { data, loading, error, reload } = useAsyncData(adminListPersonas, []); + useAutoReload(reload, 10000); + const [selected, setSelected] = useState(null); + const log = useAuditStore((s) => s.log); + + return ( +
+ { + log("persona.create", "ok"); + alert("新建人格:后端 M10+ 提供,当前为占位操作。"); + }} + > + 新建人格 + + } + /> + + + p.id} + /> + + + 点击表格行查看/编辑该人格(编辑为本地占位,写入需后端 M10+ 提供)。 + + {selected ? ( + +
+

编辑 · {selected.name}

+ + + +
+
+ + + + +
+
+ ) : null} +
+ ); +} + +function LabeledField({ label, value }: { label: string; value: string }): React.ReactNode { + return ( + + ); +} diff --git a/frontend/web/src/admin/modules/Plugins.tsx b/frontend/web/src/admin/modules/Plugins.tsx new file mode 100644 index 0000000..7636475 --- /dev/null +++ b/frontend/web/src/admin/modules/Plugins.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListPlugins, adminSetPluginPolicy, getStoredRole } from "../../lib/api"; +import type { AdminPluginRecord } from "../../lib/types"; +import { useAutoReload } from "../hooks"; +import { useAuditStore } from "../audit"; +import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; +import { Button, Surface, Switch, Tag } from "../../components/primitives"; + +export function PluginsModule(): React.ReactNode { + const { data, loading, error, reload } = useAsyncData(adminListPlugins, []); + useAutoReload(reload, 10000); + const isOwner = getStoredRole() === "owner"; + const log = useAuditStore((s) => s.log); + const [busy, setBusy] = useState(null); + const [fail, setFail] = useState(null); + + const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => { + setBusy(name); + setFail(null); + adminSetPluginPolicy(name, allowInGroup, allowInStranger) + .then(() => { + log(`policy.set:${name}`, "ok"); + reload(); + }) + .catch((err: unknown) => { + log(`policy.set:${name}`, "error"); + setFail(err instanceof Error ? err.message : "策略更新失败"); + }) + .finally(() => setBusy(null)); + }; + + const columns: AdminColumn[] = [ + { key: "name", title: "名称", render: (p) => {p.name} }, + { key: "kind", title: "类型", render: (p) => {p.kind} }, + { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, + { + key: "policy", + title: "环境授权(owner)", + render: (p) => ( + + + + {busy === p.name ? 保存中… : null} + + ), + }, + ]; + + return ( +
+ { + log("plugin.install", "ok"); + alert("安装/卸载插件:后端 M10+ 提供,当前为占位操作。"); + }} + > + 安装插件 + + } + /> + + {!isOwner ? 当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。 : null} + {fail ? 策略更新失败:{fail} : null} + + + p.name} + /> + + + 工具 schema / 技能 / MCP 服务器列表为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/Rag.tsx b/frontend/web/src/admin/modules/Rag.tsx new file mode 100644 index 0000000..13b0815 --- /dev/null +++ b/frontend/web/src/admin/modules/Rag.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { useAuditStore } from "../audit"; +import { Notice, PageHeader } from "../components"; +import { Button, Surface, Tag } from "../../components/primitives"; + +interface DocRow { + id: string; + name: string; + chunks: number; + embedded: boolean; +} + +const docs: DocRow[] = [ + { id: "1", name: "owner_identity.md", chunks: 1, embedded: false }, + { id: "2", name: "开发时间线.md", chunks: 6, embedded: false }, + { id: "3", name: "design/10-后台管理面板.md", chunks: 2, embedded: false }, +]; + +export function RagModule(): React.ReactNode { + const log = useAuditStore((s) => s.log); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState([]); + + return ( +
+ { + log("rag.upload", "ok"); + alert("上传文档(占位):后端 M10+ 提供分块/嵌入。"); + }} + > + 上传文档 + + } + /> + + +
+

文档列表

+ {docs.length} 篇 +
+ {docs.map((d) => ( +
+ {d.name} + {d.chunks} 分块 + + {d.embedded ? "已嵌入" : "待嵌入"} + + +
+ ))} +
+ + +

检索测试

+
+ setQuery(e.target.value)} + 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)]" + /> + +
+ {hits.length > 0 ? ( +
+ {hits.map((h) => ( +
{h}
+ ))} +
+ ) : null} +
+ + 分块/嵌入状态、向量索引与语义检索为后端 M10+ 提供;配置了 pgvector 增强后必须真实走语义路径(非空壳)。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/Sessions.tsx b/frontend/web/src/admin/modules/Sessions.tsx new file mode 100644 index 0000000..fdfc0ea --- /dev/null +++ b/frontend/web/src/admin/modules/Sessions.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListSessions } from "../../lib/api"; +import type { SessionRecord } 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"; + +const columns: AdminColumn[] = [ + { key: "platform", title: "渠道", render: (s) => {s.platform} }, + { key: "actor", title: "actor 元数据", render: (s) => {s.actorRole} }, + { key: "flags", title: "标记", render: (s) => ( + + {s.owner ? 主人 : null} + {s.isGroup ? 群聊 : null} + {s.memWritable ? "记忆可写" : "记忆只读"} + + ) }, + { key: "channel", title: "channelId", render: (s) => {s.channelId} }, + { key: "session", title: "sessionId", render: (s) => {s.sessionId} }, +]; + +export function SessionsModule(): React.ReactNode { + const { data, loading, error, reload } = useAsyncData(adminListSessions, []); + useAutoReload(reload, 10000); + const [query, setQuery] = useState(""); + const log = useAuditStore((s) => s.log); + + const filtered = (data ?? []).filter((s) => { + 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) + ); + }); + + return ( +
+ { + log("session.export", "ok"); + alert("导出会话(占位):后端 M10+ 提供导出。"); + }} + > + 导出 + + } + /> + + + 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)]" + /> + s.sessionId} + /> + + + 会话详情 / 上下文编辑 / 删除为后端 M10+ 提供,当前仅列表只读。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/System.tsx b/frontend/web/src/admin/modules/System.tsx new file mode 100644 index 0000000..1a5e9b5 --- /dev/null +++ b/frontend/web/src/admin/modules/System.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { getStoredRole } from "../../lib/api"; +import { useAuditStore } from "../audit"; +import { KeyValueRow, Notice, PageHeader } from "../components"; +import { Button, Surface, Tag } from "../../components/primitives"; + +export function SystemModule(): React.ReactNode { + const isOwner = getStoredRole() === "owner"; + const log = useAuditStore((s) => s.log); + const [logLevel, setLogLevel] = useState("info"); + + const sensitive = (what: string, note: string) => { + if (!isOwner) { + alert(`仅 owner 可执行:${what}。当前角色 ${getStoredRole()}。`); + return; + } + log(`system.${what}`, "ok"); + alert(`${note}(占位):后端 M10+ 提供真实执行。`); + }; + + return ( +
+ + +
+ + + + + +
+ + +
+
+ + + + + + +
+ + +
+
+ + + + + + +
+ +
+
+ + + +
+ 日志级别 + + {["debug", "info", "warn", "error"].map((lvl) => ( + + ))} + +
+
+ + +
+
+
+ + {!isOwner ? 安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。 : null} +
+ ); +} + +function SectionTitle({ icon, title, ownerOnly = false }: { icon: string; title: string; ownerOnly?: boolean }): React.ReactNode { + return ( +

+ {icon} + {title} + {ownerOnly ? owner : null} +

+ ); +} diff --git a/frontend/web/src/admin/modules/Users.tsx b/frontend/web/src/admin/modules/Users.tsx new file mode 100644 index 0000000..37a9ead --- /dev/null +++ b/frontend/web/src/admin/modules/Users.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { adminListUsers, getStoredRole } from "../../lib/api"; +import type { AdminUserRecord, OwnerProfile } from "../../lib/types"; +import { useAuditStore } from "../audit"; +import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; +import { Button, Surface, Tag } from "../../components/primitives"; + +const roleTone: Record = { + owner: "accent", + admin: "ok", + viewer: "neutral", +}; + +const columns: AdminColumn[] = [ + { key: "username", title: "用户", render: (u) => {u.username} }, + { key: "role", title: "角色", render: (u) => {u.role} }, + { key: "created", title: "创建时间", render: (u) => {u.createdAt ?? "—"} }, + { key: "id", title: "ID", render: (u) => {u.id} }, +]; + +const initialProfile: OwnerProfile = { + id: "owner", + displayName: "叶酱", + defaultPersona: "cyrene", + identityFile: "configs/users/owner_identity.md", + notes: "主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。", + accounts: [], +}; + +export function UsersModule(): React.ReactNode { + const users = useAsyncData(adminListUsers, []); + const isOwner = getStoredRole() === "owner"; + const log = useAuditStore((s) => s.log); + + const [profile, setProfile] = useState(initialProfile); + const [sampleKey, setSampleKey] = useState(null); + + const saveProfile = () => { + log("owner.profile.edit", "ok"); + setProfile({ ...profile }); + }; + + return ( +
+ { + log("user.create", "ok"); + alert("新增用户 / 角色分配(占位):后端 M10+ 提供。"); + }} + > + 新增用户 + + } + /> + + +

用户列表 · 角色(RBAC)

+ {users.error && !users.data ? ( +
+ 后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{users.error} + u.id} + /> +
+ ) : ( + u.id} /> + )} +
+ + +

主人档案编辑({isOwner ? "可编辑" : "只读 · 需 owner"})

+

+ 对应 configs/users/owner.yaml:各渠道确认的主人账号白名单(供策略引擎比对 actor 元数据)+ 关系备注。身份说明书(给 LLM 的人格知识)见 identityFile。 +

+
+ setProfile({ ...profile, id: v })} disabled={!isOwner} /> + setProfile({ ...profile, displayName: v })} disabled={!isOwner} /> + setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} /> + setProfile({ ...profile, identityFile: v })} disabled={!isOwner} /> +
+
+ 关系备注 +