feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。

【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。

【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。

【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。

【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。

【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。

实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
This commit is contained in:
Aska committed 2026-09-06 21:54:40 +08:00
1 parent e0a543cc37
commit 6dbb4a2e18
51 files changed
+5395 -287

No files matched your search

+37 -4
View File
@@ -21,7 +21,7 @@
|---|------|------|------|:---:|------|
| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,jwt}}`(24h,owner) |
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉真推理(design/06) |
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉真推理(design/06)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}` |
| 5 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` |
@@ -49,7 +49,27 @@
| 16 | `/api/v1/admin/observability/trace` | GET | ✅ | 最近 trace span(环形缓冲,默认 512 条) |
| 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) |
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。写操作(策略/档案/密钥/系统设置/用户与自动化创建、知识库上传/删除)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**插件安装(需源码清单)**、**主人档案保存**、**系统设置**、**知识库上传/删除**——后端如实返回 `501 not_implemented`,不伪造成功。
**管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`):
- `POST /admin/personas` body `{id,name,role,systemPrompt?,characterRef?,modes?/mode?,memoryNamespace?}` → `201 {data:{id,name,character,isDefault,mode,memoryNamespace}}`(注册进 persona registry + 持久化到 `configs/personas/<id>.yaml`);id 已存在回 `409 duplicate`。
- `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`;不存在回 `404`。
- `POST /admin/users` → `501 not_implemented`(无持久用户表,当前为单 admin + APIKey 现状)。
- `POST /admin/users/apikey` body `{role:"owner|admin|viewer"}` → `201 {data:{id:<掩码>, key:<明文·仅此一次>, role}}`。`key` 仅此响应出现一次。
- `DELETE /admin/users/apikey/{id}`(`id`=列表掩码)→ `200 {data:{id,revoked:true}}`;未命中 `404`。
- `POST /admin/iot/{id}/control` body `{action,params?}` → `200 {data:{id,name,type,state,online}}`(真实下发到 IoT provider);`404` 设备不存在 / `400` 动作不支持或参数错 / `409` 设备离线。
- `POST /admin/iot/scenarios`、`POST /admin/iot/rules` → `501 not_implemented`(IoT 无场景/规则存储)。
- `POST /admin/automations` body `{name,kind:"cron|event",expression}` → `201 {data:{id,name,kind}}`(真实注册规则);无效 cron/规则回 `400 invalid_cron|invalid_rule`。
- `POST /admin/automations/{id}/run` → `200 {data:{id,output}}`(手动触发);`404`。
- `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。
- `POST /admin/llm` body `{name,model,baseUrl?,multimodal?,fallback?}` → `201 {data:{provider,temporary:true}}`。**临时**,进程生命周期,不入 config、不绑定模型路由。
- `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。
- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`500`(凭据缺失等连接失败)。
- `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。
- `POST /admin/plugins/install` → `501 not_implemented`(需插件源码清单)。
- `POST /admin/plugins/uninstall` body `{name}` → `200 {data:{name,uninstalled:true}}`(真实从 registry 移除);`404`。
- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。
- `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。
**知识库 RAG 契约**:
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
@@ -64,7 +84,7 @@
## 3. WebSocket `/ws/chat`(实时)
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?}`(`turnId`/`seq` 为流式关联字段,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。语音回复走 `voice_output`(定向回请求连接,非广播)。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?}`(`turnId`/`seq` 为流式关联字段,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)
- **事件壳**:`{type, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。
@@ -73,19 +93,32 @@
- **`tool_call.start`**(`{id, index}`) / **`tool_call.delta`**(`{id, index, delta}`) / **`tool_call.end`**(`{id, index}`):工具调用中间态;`tool_call.delta` 携带参数增量(如 Anthropic `input_json_delta`)。
- **`tool_execution.update`**(`{id, index, delta}`):工具执行进度/局部输出(保留,工具循环 §1.2 后置实现)。
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query`。
- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query` / `interrupt`。
- **`interrupt`**(上行,客户端→服务端):`{sessionId}` → 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)、清空其待处理队列、回 `session_interrupted`;用户说「停下/算了/换个话题」时发。命中空闲会话为 no-op。
- **`voice_input`**(上行,客户端→服务端):`_{audio?, format?, text?, sessionId?}`。`audio` 为 base64(或 data: URI)音频,`format` 为其编码(`wav`/`mp3`/`ogg`);`text` 为已转写文本时跳过 STT。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 `voice_output` 定向回该连接。无注入语音服务时回 `error` 帧(`voice unavailable`)。
- **`voice_output`**(下行,服务端→客户端):`{audio, format, text, reply}`。`audio` 为 base64 合成音频,`format` 为其编码,`text` 为识别文本,`reply` 为助手回复文本。
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
---
### 3.2 会话编排状态事件(design/15)
- **触发**:同一会话内,`chat` 到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息**不阻塞**、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。
- **`session_busy`**:会话进入忙碌态(`{sessionId, mode}`,`mode` ∈ `idle|thinking|executing|subagent`)。
- **`pending_ack`**:忙时新消息已入队(`{sessionId, mode, queueDepth}`)——「收到,稍后回」。
- **`pending_drain`**:忙完开始消费下一待处理消息(`{sessionId, mode, queueDepth}`)。
- **`session_idle`**:会话回到空闲、队列清空(`{sessionId, mode}`)。
- **`session_interrupted`**:用户 `interrupt` 中断,当前任务取消、pending 清空(`{sessionId, mode, dropped}`)。
- **`pending_full`**:单会话 pending 达上限(默认 5)拒绝入队(`{sessionId, mode, max}`)——防无限积压(design/15 §4)。
- **上下文续接**:忙完重开时新回合**带上一轮上下文**(`{mode, queueDepth}` 之外的续接由服务端完成),非清空重来(design/15 §2.2)。
## 4. 客户端接入指引
- **认证**: 先 `POST /api/v1/auth/login` 取 JWT → WS 握手(`?token=`)注入。
- **版本**: 客户端请求头声明 `X-API-Version: 1`。
- **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。
## 5. 变更记录
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(临时记录)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
- `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。
- `v0.4`(结构化流):WS 下行新增 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 结构化流事件(event shell 补 `turnId`/`seq`,`data` 类型化),对齐 OpenClaw AssistantMessageEvent;保留 `stream_chunk`/`stream_end` 向后兼容。
+53
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -23,6 +24,7 @@ import (
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
@@ -314,9 +316,13 @@ func (a *app) wireRuntime() error {
a.dispatcher.Invoke,
)
capDecider := a.buildCapacity()
a.loop = loop.New(loop.Options{
Agent: ag,
Tools: a.dispatcher,
Capacity: capDecider,
OnCapacity: a.onCapacity,
Memory: store,
Retriever: a.retriever,
Extractor: memory.NewManualExtractor(personaID, "owner"),
@@ -344,6 +350,53 @@ func (a *app) wireRuntime() error {
return nil
}
// buildCapacity constructs the awakened-capacity decider when enabled, or nil
// (which keeps the loop's default all-tools behavior). It is opt-in via
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and
// falls back to built-in defaults on a parse error.
func (a *app) buildCapacity() *capacity.Decider {
if !a.cfg.Capacity.Enabled {
return nil
}
sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON)
if err != nil {
a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err)
sets = nil
}
return capacity.New(capacity.Config{
Enabled: true,
MinConfidence: a.cfg.Capacity.MinConfidence,
ToolSets: sets,
}, capacity.WithLogger(a.log))
}
// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken
// transition for observability. A future frontend wire (design/14 §5) will render
// the "能力点亮" progression from these events.
func (a *app) onCapacity(ev capacity.Event) {
a.log.Debug("capacity event",
"type", ev.Type, "from", ev.FromState, "to", ev.ToState,
"tool", ev.Tool, "at", ev.At)
}
// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of
// state→allowed tool name list. A blank string yields nil (built-in defaults); a
// null value for a state means unrestricted (all tools).
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var m map[string][]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil, fmt.Errorf("capacity tool sets: %w", err)
}
out := make(map[capacity.State][]string, len(m))
for k, v := range m {
out[capacity.State(k)] = v
}
return out, nil
}
// wireAuxiliary builds the execution provider, task manager, IoT manager,
// channel session manager, automations and background thinking services.
func (a *app) wireAuxiliary() error {
+7 -1
View File
@@ -75,6 +75,7 @@ export interface AdminTableProps<T> {
error: string | null;
emptyTitle?: string;
rowKey: (row: T) => string;
onRowClick?: (row: T) => void;
}
/** A strict, generic data table. Loading/error/empty states are handled here so
@@ -86,6 +87,7 @@ export function AdminTable<T>({
error,
emptyTitle = "暂无数据",
rowKey,
onRowClick,
}: AdminTableProps<T>): ReactNode {
if (loading) {
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
@@ -113,7 +115,11 @@ export function AdminTable<T>({
</thead>
<tbody>
{rows.map((row) => (
<tr key={rowKey(row)} className="border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60">
<tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={`border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60 ${onRowClick ? "cursor-pointer" : ""}`}
>
{columns.map((c) => (
<td key={c.key} className={`px-3 py-2.5 align-top ${c.className ?? ""}`}>
{c.render(row)}
+98 -23
View File
@@ -1,30 +1,84 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListAutomations, isNotImplemented } from "../../lib/api";
import {
adminListAutomations,
adminCreateAutomation,
adminRunAutomation,
adminDeleteAutomation,
isNotImplemented,
} from "../../lib/api";
import type { AdminAutomationRecord } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
interface ReminderRow {
id: string;
name: string;
kind: string;
cron: string;
enabled: boolean;
}
const sample: ReminderRow[] = [
const sample: AdminAutomationRecord[] = [
{ 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 { data, error, reload } = useAsyncData(adminListAutomations, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ReminderRow[]>(sample);
const backend = !isNotImplemented(error);
const rows = backend && data && data.length > 0 ? data : sample;
const [name, setName] = useState("");
const [kind, setKind] = useState("cron");
const [expr, setExpr] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const create = () => {
setBusy(true);
setMsg(null);
adminCreateAutomation({ name, kind, expression: expr })
.then((r) => {
log("automation.create", "ok");
setMsg(`已创建 ${r.name}(${r.kind})`);
setName("");
setExpr("");
reload();
})
.catch((e: unknown) => {
log("automation.create", "error");
setMsg(e instanceof Error ? e.message : "创建失败");
})
.finally(() => setBusy(false));
};
const run = (id: string, label: string) => {
setBusy(true);
setMsg(null);
adminRunAutomation(id)
.then((r) => {
log("automation.run", "ok");
setMsg(`已触发 ${label}:${JSON.stringify(r.output)}`);
})
.catch((e: unknown) => {
log("automation.run", "error");
setMsg(e instanceof Error ? e.message : "触发失败");
})
.finally(() => setBusy(false));
};
const del = (id: string, label: string) => {
setBusy(true);
setMsg(null);
adminDeleteAutomation(id)
.then(() => {
log("automation.delete", "ok");
setMsg(`已删除 ${label}`);
reload();
})
.catch((e: unknown) => {
log("automation.delete", "error");
setMsg(e instanceof Error ? e.message : "删除失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
@@ -32,33 +86,51 @@ export function AutomationsModule(): React.ReactNode {
title="自动化与提醒"
subtitle="提醒 + cron + 消息队列事件 + 后台任务(含终端任务) + 运行日志"
actions={
<Button
variant="primary"
onClick={() => {
log("automation.create", "ok");
alert("新增自动化(占位):后端 M10+ 提供 CRUD。");
}}
>
<Button variant="primary" disabled={busy || !name || !expr} onClick={create}>
新增自动化
</Button>
}
/>
{msg ? <Notice tone={msg.includes("失败") ? "err" : "ok"}>{msg}</Notice> : null}
{backend && !error ? <Notice tone="ok">后端已返回自动化配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/automations,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">提醒 / CRON / 队列事件</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称</span>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">类型</span>
<select value={kind} onChange={(e) => setKind(e.target.value)} className={inputCls}>
<option value="cron">cron</option>
<option value="event">event</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">表达式(cron / bus subject)</span>
<input value={expr} onChange={(e) => setExpr(e.target.value)} placeholder="0 22 * * *" className={inputCls} />
</label>
</div>
{rows.map((r) => (
<div key={r.id} className="flex items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5 text-sm">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.kind}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.cron}</span>
<span className="ml-auto">
<Switch checked={r.enabled} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" />
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.cron || "—"}</span>
<span className="ml-auto flex items-center gap-2">
<Switch checked={r.enabled} disabled onChange={() => {}} label="启用" />
<Button size="sm" variant="ghost" disabled={busy} onClick={() => run(r.id, r.name)}>
触发
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => del(r.id, r.name)}>
删除
</Button>
</span>
<Button size="sm" variant="ghost" onClick={() => { log("automation.run", "ok"); alert(`手动触发 ${r.name}(占位)`); }}>触发</Button>
</div>
))}
</Surface>
@@ -77,3 +149,6 @@ export function AutomationsModule(): React.ReactNode {
</div>
);
}
const inputCls =
"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)]";
+91 -41
View File
@@ -1,52 +1,101 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListChannels } from "../../lib/api";
import type { AdminChannelRecord } from "../../lib/types";
import { adminListChannels, adminChannelOpen, adminChannelClose, adminChannelTest } from "../../lib/api";
import type { AdminChannelRecord, ChannelTestResult } 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<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{
key: "connected",
title: "状态",
render: (c) => (
<Tag tone={c.connected ? "ok" : "err"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
{c.connected ? "已连接" : "未连接"}
</Tag>
),
},
];
export function ChannelsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [ownerId, setOwnerId] = useState("");
const [platform, setPlatform] = useState("");
const [group, setGroup] = useState(false);
const [verdict, setVerdict] = useState<string | null>(null);
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
const toggle = (name: string, connect: boolean) => {
setBusy(name);
setErr(null);
const call = connect ? adminChannelOpen(name) : adminChannelClose(name);
call
.then(() => {
log(connect ? "channel.open" : "channel.close", "ok");
reload();
})
.catch((e: unknown) => {
log(connect ? "channel.open" : "channel.close", "error");
setErr(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
})
.finally(() => setBusy(null));
};
const test = () => {
if (!ownerId.trim()) {
setErr("邮箱/账号 id 不能为空");
return;
}
setBusy("__test__");
setErr(null);
adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group })
.then((v) => {
log("channel.identity-test", "ok");
setVerdict(v);
})
.catch((e: unknown) => {
log("channel.identity-test", "error");
setErr(e instanceof Error ? e.message : "身份测试失败");
})
.finally(() => setBusy(null));
};
const columns: AdminColumn<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{
key: "connected",
title: "状态",
render: (c) => (
<Tag tone={c.connected ? "ok" : "err"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
{c.connected ? "已连接" : "未连接"}
</Tag>
),
},
{
key: "actions",
title: "操作",
render: (c) => (
<span className="flex items-center gap-2">
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
连接
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null || !c.connected} onClick={() => toggle(c.name, false)}>
断开
</Button>
</span>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="渠道管理"
subtitle="GET /api/v1/admin/channels · 类型/账号/状态/健康 · 身份识别基于 actor 元数据"
subtitle="GET/POST /api/v1/admin/channels · 连接/断开(真实生命周期) + 身份识别基于 actor 元数据 + 白名单"
actions={
<Button
variant="soft"
onClick={() => {
log("channel.reconnect", "ok");
alert("热重载/重连(占位):后端 M10+ 提供。");
}}
>
热重载
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
</Button>
}
/>
{err ? <Notice tone="err">{err}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
columns={columns}
@@ -59,11 +108,17 @@ export function ChannelsModule(): React.ReactNode {
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(占位 · 策略引擎 M10 接入)</h3>
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
输入渠道确认的发送者账号 id,模拟 channel 层 actor 元数据 → 策略引擎判定;判定绝不依赖提示词 / LLM 自我判断。
输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={platform}
onChange={(e) => setPlatform(e.target.value)}
placeholder="platform(如 weixin-ilink,可留空)"
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)]"
/>
<input
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
@@ -74,24 +129,19 @@ export function ChannelsModule(): React.ReactNode {
<input type="checkbox" checked={group} onChange={(e) => setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" />
群聊
</label>
<Button
variant="primary"
onClick={() => {
log("channel.identity-test", "ok");
setVerdict(
group
? "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
: "匹配主人白名单 → owner;若未命中则按陌生人处理(default deny)"
);
}}
>
<Button variant="primary" disabled={busy !== null} onClick={test}>
测试
</Button>
</div>
{verdict ? <div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">判定:{verdict}</div> : null}
{verdict ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
<div>角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}</div>
<div>判定:{verdict.verdict}</div>
</div>
) : null}
</Surface>
<Notice>账号配置(扫码/token)、消息流预览、推送测试为后端 M10+ 提供;此页当前展示渠道注册与连接健康。</Notice>
<Notice>账号配置(扫码/token)、消息流预览、推送测试由后端 M10+ 提供;此页当前为注册渠道 + 连接健康 + 结构性身份识别。</Notice>
</div>
);
}
+95 -18
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListIoT } from "../../lib/api";
import { adminListIoT, adminIoTControl, adminIoTScenario, adminIoTRule } from "../../lib/api";
import type { AdminIoTDevice } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
@@ -31,42 +32,118 @@ const columns: AdminColumn<AdminIoTDevice>[] = [
</span>
),
},
{
key: "control",
title: "控制",
render: (d) => (
<DeviceControl device={d} disabled={!d.online} />
),
},
];
function DeviceControl({ device, disabled }: { device: AdminIoTDevice; disabled: boolean }): React.ReactNode {
const [action, setAction] = useState("on");
const [params, setParams] = useState("");
const [res, setRes] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const log = useAuditStore((s) => s.log);
const send = () => {
setBusy(true);
setErr(null);
let parsed: Record<string, unknown> | undefined;
if (params.trim()) {
try {
parsed = JSON.parse(params) as Record<string, unknown>;
} catch {
setErr("参数需为合法 JSON");
setBusy(false);
return;
}
}
adminIoTControl(device.id, action.trim() || "on", parsed)
.then((d) => {
log("iot.control", "ok");
setRes(`${d.name} → ${action}:${JSON.stringify(d.state)}`);
})
.catch((e: unknown) => {
log("iot.control", "error");
setErr(e instanceof Error ? e.message : "控制失败");
})
.finally(() => setBusy(false));
};
return (
<div className="flex flex-wrap items-center gap-1.5">
<input
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="on / off / set_temperature"
className="w-28 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<input
value={params}
onChange={(e) => setParams(e.target.value)}
placeholder='{"temp":24}'
className="w-28 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<Button size="sm" variant="ghost" disabled={disabled || busy} onClick={send}>
控制
</Button>
{res ? <span className="text-[11px] text-[var(--mu-ok)]">{res}</span> : null}
{err ? <span className="text-[11px] text-[var(--mu-err)]">{err}</span> : null}
</div>
);
}
export function IoTModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListIoT, []);
useAutoReload(reload, 8000);
const log = useAuditStore((s) => s.log);
const [msg, setMsg] = useState<string | null>(null);
const scenario = () =>
adminIoTScenario({ name: "场景-夜灯" })
.then(() => {
log("iot.scene", "ok");
setMsg("场景保存成功");
})
.catch((e: unknown) => {
log("iot.scene", "error");
setMsg(e instanceof Error ? e.message : "场景保存失败");
});
const rule = () =>
adminIoTRule({ name: "规则-定时", expression: "0 22 * * *" })
.then(() => {
log("iot.rule", "ok");
setMsg("规则保存成功");
})
.catch((e: unknown) => {
log("iot.rule", "error");
setMsg(e instanceof Error ? e.message : "规则保存失败");
});
return (
<div className="space-y-5">
<PageHeader
title="IoT 设备"
subtitle="GET /api/v1/admin/iot · 设备列表/实时状态 + 控制台 + 场景 + 自动化规则"
subtitle="GET /api/v1/admin/iot · 设备列表/实时状态 + 控制(真实下发) + 场景 + 自动化规则"
actions={
<>
<Button
variant="soft"
onClick={() => {
log("iot.scene", "ok");
alert("场景 / 自动化规则 CRUD(占位):后端 M10+ 提供。");
}}
>
<Button variant="soft" onClick={scenario}>
场景与规则
</Button>
<Button
variant="primary"
onClick={() => {
log("iot.control", "ok");
alert("设备控制(占位):后端 M10+ 提供指令下发。");
}}
>
设备控制台
<Button variant="primary" onClick={rule}>
新增规则
</Button>
</>
}
/>
{msg ? <Notice tone="err">{msg}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
columns={columns}
@@ -78,7 +155,7 @@ export function IoTModule(): React.ReactNode {
/>
</Surface>
<Notice>实时上/下线经 WS device_update 事件或轮询刷新;控制/场景/规则写入需后端 M10+ 提供。</Notice>
<Notice>设备控制真实下发到 IoT provider(在线设备可操作);场景/规则存储由后端 M10 提供,当前返回明确待实现。</Notice>
</div>
);
}
+93 -30
View File
@@ -1,43 +1,77 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, isNotImplemented } from "../../lib/api";
import { adminGetLlm, adminAddLlmProvider, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types";
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 },
];
const sample: AdminLlmConfig = {
providers: [
{ 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 { data, error, reload } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ProviderRow[]>(sample);
const backend = !isNotImplemented(error);
const providers = backend && data ? data.providers : sample.providers;
const [name, setName] = useState("");
const [model, setModel] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [multimodal, setMultimodal] = useState(false);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const addProvider = () => {
setBusy(true);
setFail(null);
setResult(null);
adminAddLlmProvider({ name, model, baseUrl, multimodal })
.then(({ provider }) => {
log("llm.addProvider", "ok");
setResult(`已记录 ${provider.name}(进程生命周期,未绑定模型路由)`);
setName("");
setModel("");
setBaseUrl("");
setMultimodal(false);
reload();
})
.catch((e: unknown) => {
log("llm.addProvider", "error");
setFail(e instanceof Error ? e.message : "新增模型失败");
})
.finally(() => setBusy(false));
};
const test = (p: AdminLlmProvider) => {
setBusy(true);
setFail(null);
setResult(null);
const providerId = p.id === "anthropic" ? "anthropic" : "openai";
adminTestLlm({ provider: providerId, baseUrl: p.baseUrl, model: p.model })
.then((r) => {
log("llm.test", "ok");
setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`);
})
.catch((e: unknown) => {
log("llm.test", "error");
setFail(e instanceof Error ? e.message : "模型测试失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试 + fallback + 按角色路由 + 用量"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试(真实调用) + fallback + 按角色路由 + 用量"
actions={
<Button
variant="primary"
onClick={() => {
log("llm.addProvider", "ok");
alert("新增模型(占位):后端 M10+ 提供真实持久化。");
}}
>
<Button variant="primary" disabled={busy || !name || !model} onClick={addProvider}>
新增模型
</Button>
}
@@ -46,23 +80,49 @@ export function LlmModule(): React.ReactNode {
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/llm,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{rows.map((r) => (
{providers.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.baseUrl}</span>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.baseUrl || "—"}</span>
<span className="ml-auto flex items-center gap-4">
<Switch checked={r.enabled} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" />
<Switch checked={r.multimodal} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, multimodal: v } : x)))} label="多模态" />
<Button size="sm" variant="ghost" onClick={() => { log("llm.test", "ok"); alert(`测试 ${r.name}(占位)`); }}>测试</Button>
<Tag tone={r.enabled ? "ok" : "neutral"}>{r.enabled ? "启用" : "停用"}</Tag>
<Tag tone={r.multimodal ? "accent" : "neutral"}>{r.multimodal ? "多模态" : "文本"}</Tag>
<Button size="sm" variant="ghost" disabled={busy || !r.baseUrl} onClick={() => test(r)}>
测试
</Button>
</span>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新增模型(进程生命周期,未绑定路由)</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称</span>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
<input value={model} onChange={(e) => setModel(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="flex items-center gap-2">
<Switch checked={multimodal} onChange={setMultimodal} label="多模态" />
<span className="text-xs text-[var(--mu-text-lo)]">API key 不入库、不进 wire、不泄露;测试端点每次用请求内的 key 直接调用。</span>
</div>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
{["owner", "admin", "viewer"].map((role) => (
@@ -74,7 +134,10 @@ export function LlmModule(): React.ReactNode {
))}
</Surface>
<Notice>配置写入 / 多模态检测 / 用量费用统计为后端 M10+ 提供;per-role 路由 + 降级逻辑由后端 loop 实现。</Notice>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;新增模型记录为进程生命周期临时(不持久化/config 不变)。</Notice>
</div>
);
}
const inputCls =
"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)]";
+76 -25
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListMemories } from "../../lib/api";
import type { MemoryRecord } from "../../lib/types";
import { adminListMemories, adminMemorySearch } from "../../lib/api";
import type { MemoryRecord, MemorySearchHit } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
@@ -13,16 +13,26 @@ const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
project: "warn",
};
const columns: AdminColumn<MemoryRecord>[] = [
{ key: "scope", title: "作用域", render: (m) => <Tag tone={scopeTone[m.scope] ?? "neutral"}>{m.scope}</Tag> },
{ key: "text", title: "内容", render: (m) => <span className="block max-w-md truncate">{m.text}</span> },
{ key: "id", title: "ID", render: (m) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{m.id}</span> },
];
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> },
{ key: "text", title: "内容", render: (m) => <span className="block max-w-md truncate">{m.text}</span> },
{ 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} />
);
}
export function MemoriesModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListMemories, []);
useAutoReload(reload, 15000);
const [query, setQuery] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [personaId, setPersonaId] = useState("cyrene");
const [hits, setHits] = useState<MemorySearchHit[] | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((m) => {
@@ -31,42 +41,83 @@ export function MemoriesModule(): React.ReactNode {
return m.text.toLowerCase().includes(q) || m.scope.toLowerCase().includes(q) || m.id.toLowerCase().includes(q);
});
const search = () => {
if (!searchQuery.trim()) {
setFail("测试查询不能为空");
return;
}
setBusy(true);
setFail(null);
adminMemorySearch({ query: searchQuery.trim(), personaId: personaId.trim() || "cyrene" })
.then((r) => {
log("memory.vectorTest", "ok");
setHits(r.hits);
})
.catch((e: unknown) => {
log("memory.vectorTest", "error");
setFail(e instanceof Error ? e.message : "向量测试失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="记忆管理"
subtitle="GET /api/v1/admin/memories · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
subtitle="GET /api/v1/admin/memories + POST /memories/search · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
actions={
<Button
variant="soft"
onClick={() => {
log("memory.vectorTest", "ok");
alert("向量检索测试(占位):后端 M10+ 提供语义/向量路径。");
}}
>
<Button variant="soft" disabled={busy} onClick={search}>
向量测试
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">语义/关键词检索测试(真实 retrieval)</h3>
<div className="flex flex-wrap items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(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)]"
/>
<input
value={personaId}
onChange={(e) => setPersonaId(e.target.value)}
placeholder="personaId"
className="w-32 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>
{hits ? (
<div className="space-y-1">
{hits.length === 0 ? (
<span className="text-xs text-[var(--mu-text-lo)]">无命中</span>
) : (
hits.map((h) => (
<div key={h.id} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs">
<Tag tone="neutral">{h.scope}</Tag> <span className="ml-1 font-mono text-[var(--mu-text-lo)]">{h.score.toFixed(3)}</span>
<div className="mt-1 text-[var(--mu-text-mid)]">{h.text}</div>
</div>
))
)}
<span className="text-[11px] text-[var(--mu-text-lo)]">当前为 keyword×decay 路径;语义/向量路径需主会话注入 vector index(wire.go 装配)。</span>
</div>
) : null}
</Surface>
<Surface className="space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="按内容 / 作用域 / ID 检索…"
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)]"
/>
<AdminTable
columns={columns}
rows={loading ? null : filtered}
loading={loading}
error={error}
emptyTitle="无记忆数据"
rowKey={(m) => m.id}
/>
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
</Surface>
<Notice>编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。</Notice>
<Notice>编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。</Notice>
</div>
);
}
+156 -32
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPersonas } from "../../lib/api";
import { adminListPersonas, adminCreatePersona, adminUpdatePersona } from "../../lib/api";
import type { PersonaRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
@@ -14,30 +14,103 @@ const columns: AdminColumn<PersonaRecord>[] = [
title: "默认",
render: (p) => (p.isDefault ? <Tag tone="accent">默认</Tag> : <span className="text-[var(--mu-text-lo)]">—</span>),
},
{ key: "mode", title: "模式", render: (p) => <Tag tone="neutral">{p.mode}</Tag> },
{ key: "memory", title: "记忆域", render: (p) => <span className="font-mono text-xs">{p.memoryNamespace}</span> },
{ key: "mode", title: "模式", render: (p) => <Tag tone="neutral">{p.mode || "—"}</Tag> },
{ key: "memory", title: "记忆域", render: (p) => <span className="font-mono text-xs">{p.memoryNamespace || "—"}</span> },
{ key: "id", title: "ID", render: (p) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.id}</span> },
];
interface Draft {
id: string;
name: string;
role: string;
systemPrompt: string;
mode: string;
memoryNamespace: string;
}
const emptyDraft: Draft = { id: "", name: "", role: "", systemPrompt: "", mode: "", memoryNamespace: "" };
export function PersonasModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
useAutoReload(reload, 10000);
const [selected, setSelected] = useState<PersonaRecord | null>(null);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const log = useAuditStore((s) => s.log);
const startCreate = () => {
setDraft(emptyDraft);
setCreating(true);
setSelected(null);
setFail(null);
};
const editDraftFrom = (p: PersonaRecord): Draft => ({
id: p.id,
name: p.name,
role: p.character || "",
systemPrompt: "",
mode: p.mode || "",
memoryNamespace: p.memoryNamespace || "",
});
const create = () => {
setBusy(true);
setFail(null);
adminCreatePersona({
id: draft.id,
name: draft.name,
role: draft.role,
systemPrompt: draft.systemPrompt,
mode: draft.mode,
memoryNamespace: draft.memoryNamespace,
})
.then((p) => {
log("persona.create", "ok");
setCreating(false);
setDraft(emptyDraft);
setSelected(p);
reload();
})
.catch((err: unknown) => {
log("persona.create", "error");
setFail(err instanceof Error ? err.message : "创建人格失败");
})
.finally(() => setBusy(false));
};
const save = () => {
if (!selected) return;
setBusy(true);
setFail(null);
adminUpdatePersona(selected.id, {
name: draft.name,
role: draft.role,
systemPrompt: draft.systemPrompt,
mode: draft.mode,
memoryNamespace: draft.memoryNamespace,
})
.then((p) => {
log("persona.edit", "ok");
setSelected(p);
reload();
})
.catch((err: unknown) => {
log("persona.edit", "error");
setFail(err instanceof Error ? err.message : "保存人格失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="人格管理"
subtitle="GET /api/v1/admin/personas · 身份/情绪曲线/好感度/模型路由/工具标记"
subtitle="GET/POST/PUT /api/v1/admin/personas · 新建/编辑真实持久化到 personas 存储/YAML"
actions={
<Button
variant="primary"
onClick={() => {
log("persona.create", "ok");
alert("新建人格:后端 M10+ 提供,当前为占位操作。");
}}
>
<Button variant="primary" onClick={startCreate}>
新建人格
</Button>
}
@@ -49,35 +122,77 @@ export function PersonasModule(): React.ReactNode {
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无人格数据(后端未接线)"
emptyTitle="无人格数据"
rowKey={(p) => p.id}
onRowClick={(p) => {
setSelected(p);
setDraft(editDraftFrom(p));
setCreating(false);
setFail(null);
}}
/>
</Surface>
<Notice>点击表格行查看/编辑该人格(编辑为本地占位,写入需后端 M10+ 提供)。</Notice>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{selected ? (
{creating ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新建人格</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="ID *" value={draft.id} onChange={(v) => setDraft({ ...draft, id: v })} />
<Field label="名称 *" value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} />
<Field label="人设 role *" value={draft.role} onChange={(v) => setDraft({ ...draft, role: v })} />
<Field label="行为模式 mode" value={draft.mode} onChange={(v) => setDraft({ ...draft, mode: v })} />
<Field label="记忆域 memoryNamespace" value={draft.memoryNamespace} onChange={(v) => setDraft({ ...draft, memoryNamespace: v })} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">身份说systemPrompt(可留空,自动生成)</span>
<textarea
value={draft.systemPrompt}
onChange={(e) => setDraft({ ...draft, systemPrompt: e.target.value })}
rows={3}
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>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !draft.id || !draft.name || !draft.role} onClick={create}>
{busy ? "创建中…" : "创建"}
</Button>
<Button variant="ghost" disabled={busy} onClick={() => setCreating(false)}>
取消
</Button>
</div>
</Surface>
) : null}
{selected && !creating ? (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">编辑 · {selected.name}</h3>
<span className="ml-auto">
<Button
size="sm"
variant="soft"
onClick={() => {
log("persona.edit", "ok");
alert("保存(本地占位):后端 M10+ 提供真实持久化。");
}}
>
保存(模拟)
</Button>
</span>
<span className="ml-auto font-mono text-xs text-[var(--mu-text-lo)]">{selected.id}</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<LabeledField label="ID" value={selected.id} />
<LabeledField label="记忆域" value={selected.memoryNamespace} />
<LabeledField label="角色模式" value={selected.mode} />
<LabeledField label="角色描述" value={selected.character} />
<Field label="名称 *" value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} />
<Field label="人设 role *" value={draft.role} onChange={(v) => setDraft({ ...draft, role: v })} />
<Field label="行为模式 mode" value={draft.mode} onChange={(v) => setDraft({ ...draft, mode: v })} />
<Field label="记忆域 memoryNamespace" value={draft.memoryNamespace} onChange={(v) => setDraft({ ...draft, memoryNamespace: v })} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">身份说 systemPrompt(留空则自动生成)</span>
<textarea
value={draft.systemPrompt}
onChange={(e) => setDraft({ ...draft, systemPrompt: e.target.value })}
rows={3}
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>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !draft.name || !draft.role} onClick={save}>
{busy ? "保存中…" : "保存"}
</Button>
<Button variant="ghost" disabled={busy} onClick={() => setSelected(null)}>
关闭
</Button>
</div>
</Surface>
) : null}
@@ -85,12 +200,21 @@ export function PersonasModule(): React.ReactNode {
);
}
function LabeledField({ label, value }: { label: string; value: string }): React.ReactNode {
function Field({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
defaultValue={value}
value={value}
onChange={(e) => onChange(e.target.value)}
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)]"
/>
</label>
+48 -9
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPlugins, adminSetPluginPolicy, getStoredRole } from "../../lib/api";
import { adminListPlugins, adminSetPluginPolicy, adminPluginInstall, adminPluginUninstall, getStoredRole } from "../../lib/api";
import type { AdminPluginRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
@@ -14,6 +14,41 @@ export function PluginsModule(): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const install = () => {
setBusy("__install__");
setFail(null);
setMsg(null);
adminPluginInstall({ name: "sample-plugin", kind: "tool", description: "手工安装示例" })
.then(() => {
log("plugin.install", "ok");
setMsg("插件安装成功");
reload();
})
.catch((err: unknown) => {
log("plugin.install", "error");
setFail(err instanceof Error ? err.message : "插件安装失败");
})
.finally(() => setBusy(null));
};
const uninstall = (name: string) => {
setBusy(name);
setFail(null);
setMsg(null);
adminPluginUninstall({ name })
.then(() => {
log("plugin.uninstall", "ok");
setMsg(`已卸载 ${name}`);
reload();
})
.catch((err: unknown) => {
log("plugin.uninstall", "error");
setFail(err instanceof Error ? err.message : "卸载失败");
})
.finally(() => setBusy(null));
};
const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => {
setBusy(name);
@@ -59,6 +94,15 @@ export function PluginsModule(): React.ReactNode {
</span>
),
},
{
key: "actions",
title: "操作",
render: (p) => (
<Button size="sm" variant="ghost" disabled={!isOwner || busy !== null} onClick={() => uninstall(p.name)}>
卸载
</Button>
),
},
];
return (
@@ -67,20 +111,15 @@ export function PluginsModule(): React.ReactNode {
title="插件与能力"
subtitle="GET /api/v1/admin/plugins · 启停/安装/卸载/MCP 连接 · POST /api/v1/admin/plugins/policy (owner-only)"
actions={
<Button
variant="primary"
onClick={() => {
log("plugin.install", "ok");
alert("安装/卸载插件:后端 M10+ 提供,当前为占位操作。");
}}
>
<Button variant="primary" disabled={busy !== null} onClick={install}>
安装插件
</Button>
}
/>
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。</Notice> : null}
{fail ? <Notice tone="err">策略更新失败:{fail}</Notice> : null}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{msg ? <Notice tone="ok">{msg}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
+60 -24
View File
@@ -1,30 +1,28 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListSessions } from "../../lib/api";
import { adminListSessions, adminExportSession } 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<SessionRecord>[] = [
{ key: "platform", title: "渠道", render: (s) => <Tag tone="accent">{s.platform}</Tag> },
{ key: "actor", title: "actor 元数据", render: (s) => <Tag tone="neutral">{s.actorRole}</Tag> },
{ 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: "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> },
];
function exportBlob(sess: SessionRecord): 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.click();
URL.revokeObjectURL(url);
}
export function SessionsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
useAutoReload(reload, 10000);
const [query, setQuery] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((s) => {
@@ -38,24 +36,62 @@ export function SessionsModule(): React.ReactNode {
);
});
const exportSession = (s: SessionRecord) => {
setBusy(s.sessionId);
setFail(null);
adminExportSession(s.sessionId)
.then(({ session }) => {
log("session.export", "ok");
exportBlob(session);
})
.catch((e: unknown) => {
log("session.export", "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> },
{
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: "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>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="会话管理"
subtitle="GET /api/v1/admin/sessions · 渠道/模式/角色/分页 · 身份识别靠 channel 层 actor 元数据"
subtitle="GET /api/v1/admin/sessions + GET /sessions/{id}/export · 渠道/模式/角色/分页 · 身份识别靠 channel 层 actor 元数据"
actions={
<Button
variant="soft"
onClick={() => {
log("session.export", "ok");
alert("导出会话(占位):后端 M10+ 提供导出。");
}}
>
导出
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<Surface className="space-y-3">
<input
value={query}
@@ -73,7 +109,7 @@ export function SessionsModule(): React.ReactNode {
/>
</Surface>
<Notice>会话详情 / 上下文编辑 / 删除为后端 M10+ 提供,当前仅列表只读。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。</Notice>
<Notice>导出当前为会话身份元数据 JSON(完整会话消息存储由后端 M10+ 提供)。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。</Notice>
</div>
);
}
+100 -36
View File
@@ -1,6 +1,12 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListUsers, getStoredRole } from "../../lib/api";
import {
adminListUsers,
adminCreateUser,
adminIssueApiKey,
adminRevokeApiKey,
getStoredRole,
} from "../../lib/api";
import type { AdminUserRecord, OwnerProfile } from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
@@ -12,13 +18,6 @@ const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
viewer: "neutral",
};
const columns: AdminColumn<AdminUserRecord>[] = [
{ key: "username", title: "用户", render: (u) => <span className="font-medium">{u.username}</span> },
{ key: "role", title: "角色", render: (u) => <Tag tone={roleTone[u.role] ?? "neutral"}>{u.role}</Tag> },
{ key: "created", title: "创建时间", render: (u) => <span className="text-xs text-[var(--mu-text-lo)]">{u.createdAt ?? "—"}</span> },
{ key: "id", title: "ID", render: (u) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{u.id}</span> },
];
const initialProfile: OwnerProfile = {
id: "owner",
displayName: "叶酱",
@@ -29,41 +28,105 @@ const initialProfile: OwnerProfile = {
};
export function UsersModule(): React.ReactNode {
const users = useAsyncData(adminListUsers, []);
const { data, error, loading, reload } = useAsyncData(adminListUsers, []);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile>(initialProfile);
const [sampleKey, setSampleKey] = useState<string | null>(null);
const [issuedKey, setIssuedKey] = useState<string | null>(null);
const [role, setRole] = useState<string>("owner");
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const saveProfile = () => {
log("owner.profile.edit", "ok");
setProfile({ ...profile });
};
const createUser = () => {
setBusy(true);
setFail(null);
adminCreateUser({ username: "new-user", role: "viewer" })
.then(() => {
log("user.create", "ok");
reload();
})
.catch((err: unknown) => {
log("user.create", "error");
setFail(err instanceof Error ? err.message : "新增用户失败");
})
.finally(() => setBusy(false));
};
const issueKey = () => {
setBusy(true);
setFail(null);
adminIssueApiKey(role)
.then((rg) => {
log("apikey.generate", "ok");
setIssuedKey(rg.key);
reload();
})
.catch((err: unknown) => {
log("apikey.generate", "error");
setFail(err instanceof Error ? err.message : "签发密钥失败");
})
.finally(() => setBusy(false));
};
const revokeKey = (id: string) => {
setBusy(true);
setFail(null);
adminRevokeApiKey(id)
.then(() => {
log("apikey.revoke", "ok");
reload();
})
.catch((err: unknown) => {
log("apikey.revoke", "error");
setFail(err instanceof Error ? err.message : "撤销密钥失败");
})
.finally(() => setBusy(false));
};
const columns: AdminColumn<AdminUserRecord>[] = [
{ key: "username", title: "用户", render: (u) => <span className="font-medium">{u.username}</span> },
{ key: "role", title: "角色", render: (u) => <Tag tone={roleTone[u.role] ?? "neutral"}>{u.role}</Tag> },
{ key: "created", title: "创建时间", render: (u) => <span className="text-xs text-[var(--mu-text-lo)]">{u.createdAt ?? "—"}</span> },
{ key: "id", title: "ID", render: (u) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{u.id}</span> },
{
key: "actions",
title: "操作",
render: (u) =>
u.username === "apikey" && isOwner ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => revokeKey(u.id)}>
撤销
</Button>
) : (
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="用户与权限"
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key · 主人档案(白名单 + 身份说明书)"
actions={
<Button
variant="soft"
onClick={() => {
log("user.create", "ok");
alert("新增用户 / 角色分配(占位):后端 M10+ 提供。");
}}
>
<Button variant="soft" disabled={!isOwner || busy} onClick={createUser}>
新增用户
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
{users.error && !users.data ? (
{error && !data ? (
<div className="space-y-2">
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{users.error}</Notice>
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{error}</Notice>
<AdminTable
columns={columns}
rows={[
@@ -77,7 +140,7 @@ export function UsersModule(): React.ReactNode {
/>
</div>
) : (
<AdminTable columns={columns} rows={users.data} loading={users.loading} error={users.error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
<AdminTable columns={columns} rows={data} loading={loading} error={error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
)}
</Surface>
@@ -106,31 +169,32 @@ export function UsersModule(): React.ReactNode {
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
保存主人档案
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;真实持久化 + 热重载需后端 M10+ 提供。</span>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;owner.yaml 持久化/热重载(编辑)由后端 M10 提供,当前为本地编辑。</span>
</div>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
{sampleKey ? (
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">
{sampleKey}
{issuedKey ? (
<div className="space-y-1">
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">{issuedKey}</div>
<span className="text-xs text-[var(--mu-text-lo)]">密钥仅此一次可见,请立即复制保存;以 X-API-Key 头访问管理端点。</span>
</div>
) : null}
<div className="flex items-center gap-2">
<Button
variant="soft"
disabled={!isOwner}
onClick={() => {
log("apikey.generate", "ok");
const raw = new Uint8Array(24);
crypto.getRandomValues(raw);
setSampleKey(Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join(""));
}}
<div className="flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="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)]"
>
签发新密钥(模拟)
<option value="owner">owner</option>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
<Button variant="soft" disabled={!isOwner || busy} onClick={issueKey}>
签发新密钥
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">以 X-API-Key 头访问管理端点;真实签发/撤销需后端 M10+。</span>
<span className="text-xs text-[var(--mu-text-lo)]">行内「撤销」真实移除该密钥。</span>
</div>
</Surface>
</div>
+3
View File
@@ -5,6 +5,7 @@ import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import type { Mode, WsStatus } from "../lib/types";
import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
import { AwakenBadge } from "./status";
const MODE_LABEL: Record<Mode, string> = {
chat: "对话", code: "代码", home: "家居", work: "工作", docs: "知识库",
@@ -56,6 +57,8 @@ export function TopBar(): React.ReactNode {
{mood ? <Tag tone="accent">心情 · {mood}</Tag> : null}
<AwakenBadge />
<Tag tone={meta.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
{meta.label}
+19 -3
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
import { WakePath } from "./status";
import { modeBody } from "./messages";
/** Collapsible inline chain-of-thought block. */
@@ -76,12 +77,18 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
* 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
* (legacy persisted messages). */
export function MessageBubble({ message }: { message: ChatMessage }): React.ReactNode {
export function MessageBubble({
message,
active = false,
}: {
message: ChatMessage;
active?: boolean;
}): React.ReactNode {
const mode = message.mode ?? "chat";
if (message.role === "user") {
return <UserBubble message={message} mode={mode} />;
}
return <AssistantBubble message={message} mode={mode} />;
return <AssistantBubble message={message} mode={mode} active={active} />;
}
/** User-side bubble. The outer accent pill is shared; the inner content may be
@@ -111,7 +118,15 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
/** Assistant-side bubble. Outer layout shared; the body renders per mode via the
* mode-specific body (chat / code / docs / home / work / vision …). */
function AssistantBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
function AssistantBubble({
message,
mode,
active = false,
}: {
message: ChatMessage;
mode: Mode;
active?: boolean;
}): React.ReactNode {
return (
<div className="mu-rise-in flex gap-3">
<img src="/icon.svg" alt="" className="mt-1 h-7 w-7 shrink-0" />
@@ -120,6 +135,7 @@ function AssistantBubble({ message, mode }: { message: ChatMessage; mode: Mode }
{message.mood ? (
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
) : null}
<WakePath active={active} />
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
{modeBody(mode, message)}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import { useAppStore } from "../store/useAppStore";
import { Tag, type TagTone } from "./primitives";
import { Icon, type IconName } from "./Icon";
import type { AwakenState } from "../lib/types";
/**
* Miuix 风格的状态呈现组件(用户端):唤醒式能力三态 + 会话编排 busy/pending。
* 全部从 useAppStore 的实时状态派生,纯展示、无副作用;未收到对应 WS 事件时
* 保持默认值(静息 / 不忙),UI 不报错。design/14 §5 + 15 §5 + 07 §3.
*/
/** 三态排序权重:静息 → 唤醒 → 全能(用于判断「唤醒上升」)。 */
const AWAKEN_RANK: Record<AwakenState, number> = { quiet: 0, awake: 1, full: 2 };
const AWAKEN_ORDER: AwakenState[] = ["quiet", "awake", "full"];
const AWAKEN_META: Record<
AwakenState,
{ label: string; icon: IconName; tone: TagTone; desc: string }
> = {
quiet: { label: "静息", icon: "moon", tone: "neutral", desc: "温柔陪伴" },
awake: { label: "唤醒", icon: "light", tone: "warn", desc: "办事中" },
full: { label: "全能", icon: "gear", tone: "accent", desc: "复杂任务" },
};
/** 顶部/状态栏的轻量状态徽标:显示当前唤醒态 + 最近点亮工具;唤醒上升时短暂脉动。 */
export function AwakenBadge(): React.ReactNode {
const state = useAppStore((s) => s.awakenState);
const from = useAppStore((s) => s.awakenFrom);
const tool = useAppStore((s) => s.awakenTool);
const [pulse, setPulse] = useState(false);
const prevRef = useRef<AwakenState>(state);
// 唤醒上升(从较低态升到较高态)时短暂发光一下,提示「她认真起来了」。
useEffect(() => {
const prev = prevRef.current;
if (from !== null && AWAKEN_RANK[state] > AWAKEN_RANK[prev]) {
setPulse(true);
const t = window.setTimeout(() => setPulse(false), 1200);
prevRef.current = state;
return () => window.clearTimeout(t);
}
prevRef.current = state;
}, [state, from]);
const meta = AWAKEN_META[state];
return (
<Tag tone={meta.tone} className={pulse ? "mu-awaken" : ""}>
<Icon name={meta.icon} size={12} />
{meta.label}
{tool ? <span className="text-[var(--mu-text-lo)]">· {tool}</span> : null}
</Tag>
);
}
/** 聊天流里的「唤醒路径」节点序列(思维导图轻量版):静息 → 唤醒 → 全能,点亮到当前态;
* 本轮点亮的工具以 ok 标签展示。仅在活跃(正在流式)消息气泡上渲染。 */
export function WakePath({
active = false,
}: {
active?: boolean;
}): React.ReactNode | null {
const state = useAppStore((s) => s.awakenState);
const from = useAppStore((s) => s.awakenFrom);
const tool = useAppStore((s) => s.awakenTool);
if (!active) return null;
const currentIdx = AWAKEN_RANK[state];
const ascended = from !== null && AWAKEN_RANK[state] > AWAKEN_RANK[from];
return (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60 px-3 py-1.5">
<span className="text-xs text-[var(--mu-text-lo)]">唤醒路径</span>
<div className="flex flex-wrap items-center gap-1">
{AWAKEN_ORDER.map((key, i) => {
const on = AWAKEN_RANK[key] <= currentIdx;
const isCurrent = key === state;
return (
<span key={key} className="flex items-center gap-1">
{i > 0 ? (
<Icon
name="arrow"
size={10}
className={on ? "text-[var(--mu-text-mid)]" : "text-[var(--mu-text-disabled)]"}
/>
) : null}
<Tag
tone={on && isCurrent ? AWAKEN_META[key].tone : "neutral"}
className={`${on ? "" : "opacity-60"} ${on && isCurrent && ascended ? "mu-awaken" : ""}`}
>
<Icon name={AWAKEN_META[key].icon} size={11} />
{AWAKEN_META[key].label}
</Tag>
</span>
);
})}
</div>
{ascended ? (
<span className="text-xs text-[var(--mu-accent-hi)]">她认真起来了</span>
) : null}
{tool ? (
<Tag tone="ok">
<Icon name="check" size={11} />
点亮 · {tool}
</Tag>
) : null}
</div>
);
}
/** 会话编排 busy/pending 轻状态提醒(design/15 §5)。她忙时显示在聊天区顶部;
* **不阻塞输入框**(输入框可继续打字),挂起消息数可见。 */
export function SessionStatusHint(): React.ReactNode | null {
const busy = useAppStore((s) => s.sessionBusy);
const pendingCount = useAppStore((s) => s.pendingCount);
if (!busy) return null;
return (
<div className="mu-rise-in flex items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/70 px-3 py-2 text-xs text-[var(--mu-text-lo)]">
<Icon name="clock" size={14} className="shrink-0 text-[var(--mu-warn)]" />
<span className="min-w-0 truncate">她正在忙,有新消息稍后回</span>
{pendingCount > 0 ? <Tag tone="warn">{pendingCount} 条待回</Tag> : null}
</div>
);
}
+141
View File
@@ -3,6 +3,7 @@ import type {
AdminChannelRecord,
AdminIoTDevice,
AdminLlmConfig,
AdminLlmProvider,
AdminObservabilityMetrics,
AdminPluginRecord,
AdminRagHit,
@@ -10,12 +11,21 @@ import type {
AdminTraceSpan,
AdminUserRecord,
ApiErrorShape,
ApiKeyIssueResult,
AuditRecord,
AutomationCreateResult,
AutomationRunResult,
ChannelTestResult,
ChatSyncResult,
LlmTestRequest,
LlmTestResult,
LoginResult,
MemoryRecord,
MemorySearchRequest,
MemorySearchResult,
Mode,
PersonaRecord,
PersonaUpsertRequest,
Role,
SessionRecord,
} from "./types";
@@ -273,6 +283,137 @@ export function adminDeleteRag(id: string): Promise<{ id: string }> {
});
}
/* --- Admin write endpoints (owner-only, audited; real store or honest 501). --- */
/** POST /api/v1/admin/personas — registers a persona (persisted to YAML + registry). */
export function adminCreatePersona(req: PersonaUpsertRequest): Promise<PersonaRecord> {
return request<PersonaRecord>("/admin/personas", {
method: "POST",
body: JSON.stringify(req),
});
}
/** PUT /api/v1/admin/personas/{id} — edits an existing persona. */
export function adminUpdatePersona(id: string, req: PersonaUpsertRequest): Promise<PersonaRecord> {
return request<PersonaRecord>(`/admin/personas/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/users — add user/role. No persistent user store yet, so the
* backend answers honestly with a 501 ("M10"). */
export function adminCreateUser(req: { username: string; role: string }): Promise<AdminUserRecord> {
return request<AdminUserRecord>("/admin/users", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/users/apikey — mints a key; the plaintext comes back once. */
export function adminIssueApiKey(role: string): Promise<ApiKeyIssueResult> {
return request<ApiKeyIssueResult>("/admin/users/apikey", {
method: "POST",
body: JSON.stringify({ role }),
});
}
/** DELETE /api/v1/admin/users/apikey/{id} — revokes a key by masked id. */
export function adminRevokeApiKey(id: string): Promise<{ id: string; revoked: boolean }> {
return request<{ id: string; revoked: boolean }>(`/admin/users/apikey/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** POST /api/v1/admin/iot/{id}/control — sends a control instruction to a device. */
export function adminIoTControl(id: string, action: string, params?: Record<string, unknown>): Promise<AdminIoTDevice> {
return request<AdminIoTDevice>(`/admin/iot/${encodeURIComponent(id)}/control`, {
method: "POST",
body: JSON.stringify({ action, params }),
});
}
/** POST /api/v1/admin/iot/scenarios — honest 501 (no scenario store yet). */
export function adminIoTScenario(req: { name: string }): Promise<{ name: string }> {
return request<{ name: string }>("/admin/iot/scenarios", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/iot/rules — honest 501 (no IoT rule store yet). */
export function adminIoTRule(req: { name: string; expression: string }): Promise<{ name: string }> {
return request<{ name: string }>("/admin/iot/rules", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/automations — registers a real cron/event automation. */
export function adminCreateAutomation(req: {
name: string;
kind: string;
expression: string;
}): Promise<AutomationCreateResult> {
return request<AutomationCreateResult>("/admin/automations", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/automations/{id}/run — manually triggers a rule. */
export function adminRunAutomation(id: string): Promise<AutomationRunResult> {
return request<AutomationRunResult>(`/admin/automations/${encodeURIComponent(id)}/run`, { method: "POST" });
}
/** DELETE /api/v1/admin/automations/{id} — removes a rule (cron or event). */
export function adminDeleteAutomation(id: string): Promise<{ id: string; deleted: boolean }> {
return request<{ id: string; deleted: boolean }>(`/admin/automations/${encodeURIComponent(id)}`, { method: "DELETE" });
}
/** POST /api/v1/admin/llm — records a provider for the process lifetime (temporary). */
export function adminAddLlmProvider(req: {
id?: string;
name: string;
baseUrl?: string;
model: string;
multimodal?: boolean;
fallback?: string;
}): Promise<{ provider: AdminLlmProvider; temporary: boolean }> {
return request<{ provider: AdminLlmProvider; temporary: boolean }>("/admin/llm", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/llm/test — performs a real model call and reports the result. */
export function adminTestLlm(req: LlmTestRequest): Promise<LlmTestResult> {
return request<LlmTestResult>("/admin/llm/test", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/channels/open — connects a registered channel adapter. */
export function adminChannelOpen(name: string): Promise<{ name: string; connected: boolean }> {
return request<{ name: string; connected: boolean }>("/admin/channels/open", {
method: "POST",
body: JSON.stringify({ name }),
});
}
/** POST /api/v1/admin/channels/close — disconnects a registered channel adapter. */
export function adminChannelClose(name: string): Promise<{ name: string; connected: boolean }> {
return request<{ name: string; connected: boolean }>("/admin/channels/close", {
method: "POST",
body: JSON.stringify({ name }),
});
}
/** POST /api/v1/admin/channels/test — structural identity test (actor × whitelist). */
export function adminChannelTest(req: {
platform: string;
accountId: string;
isGroup: boolean;
}): Promise<ChannelTestResult> {
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/plugins/uninstall — removes a capability from the registry. */
export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> {
return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
export function adminMemorySearch(req: MemorySearchRequest): Promise<MemorySearchResult> {
return request<MemorySearchResult>("/admin/memories/search", { 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`);
}
/** GET /api/v1/admin/observability/metrics — gateway request metrics. */
export function adminObservabilityMetrics(): Promise<AdminObservabilityMetrics> {
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
+128 -1
View File
@@ -40,6 +40,23 @@ export interface ToolUsed {
at: number;
}
/** 唤醒式能力三态(design/14 §1):静息·温柔陪伴 / 唤醒·办事 / 全能·复杂任务。 */
export type AwakenState = "quiet" | "awake" | "full";
/** capacity_awaken 下行事件 payload(design/14 §5):状态切换 + 本轮点亮的工具。
* `tool` 可能是工具名字符串,也可能是含 `name` 的对象,解析层统一为字符串。 */
export interface AwakenPayload {
fromState?: AwakenState;
toState?: AwakenState;
tool?: string | { name: string };
}
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */
export interface SessionStatePayload {
busy?: boolean;
pendingCount?: number;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
@@ -153,7 +170,8 @@ export interface ChatSyncResult {
* thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update
* (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood /
* tool_used / subagent_event / graph_update / reminder / device_update / auth /
* error / task_output.
* error / task_output, and the capacity/session events from design/14 §5 + 15 §5:
* capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数).
*/
export interface WsDownEvent {
type: string;
@@ -333,3 +351,112 @@ export interface OwnerAccount {
platform: string;
accountId: string;
}
/* ---------------------------------------------------------------------------
* Admin write payloads / results (POST/PUT/DELETE /api/v1/admin/*).
* The backend persists to a real store or answers honestly with a 501 / clear
* error — never a fabricated success.
* -------------------------------------------------------------------------*/
/** POST/PUT /api/v1/admin/personas body. id required on create, path on update. */
export interface PersonaUpsertRequest {
id?: string;
name: string;
characterRef?: string;
role: string;
systemPrompt?: string;
personality?: string;
speechStyle?: string;
catchphrase?: string;
background?: string;
values?: string[];
modes?: string[];
mode?: string;
memoryNamespace?: string;
}
/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */
export interface ApiKeyIssueResult {
id: string;
key: string;
role: string;
}
/** POST /api/v1/admin/automations result. */
export interface AutomationCreateResult {
id: string;
name: string;
kind: string;
}
/** POST /api/v1/admin/automations/{id}/run result. */
export interface AutomationRunResult {
id: string;
output: unknown;
}
/** POST /api/v1/admin/llm body. */
export interface LlmProviderRequest {
id?: string;
name: string;
baseUrl?: string;
model: string;
multimodal?: boolean;
fallback?: string;
}
/** POST /api/v1/admin/llm/test body. */
export interface LlmTestRequest {
provider?: string;
baseUrl: string;
apiKey?: string;
model: string;
prompt?: string;
}
/** POST /api/v1/admin/llm/test result. */
export interface LlmTestResult {
ok: boolean;
provider: string;
model: string;
reply: string;
tokens: number;
}
/** POST /api/v1/admin/channels/test result (structural identity verdict). */
export interface ChannelTestResult {
role: string;
owner: boolean;
isGroup: boolean;
env: string;
verdict: string;
}
/** POST /api/v1/admin/iot/{id}/control body (params keys depend on device type). */
export interface IoTControlRequest {
action: string;
params?: Record<string, unknown>;
}
/** POST /api/v1/admin/memories/search body. */
export interface MemorySearchRequest {
query: string;
personaId?: string;
topK?: number;
}
/** One memory retrieval hit. */
export interface MemorySearchHit {
id: string;
scope: string;
text: string;
score: number;
}
/** POST /api/v1/admin/memories/search result. */
export interface MemorySearchResult {
query: string;
personaId: string;
semantic: boolean;
hits: MemorySearchHit[];
}
+35 -1
View File
@@ -1,7 +1,7 @@
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { getAuthToken } from "./api";
import { MODES, type AgentInfo, type FlowGraph, type Mode, type ToolUsed, type WsUpEvent } from "./types";
import { MODES, type AgentInfo, type AwakenState, type FlowGraph, type Mode, type ToolUsed, type WsUpEvent } from "./types";
import { now, uid } from "./util";
/**
@@ -78,6 +78,15 @@ function parseTool(v: unknown): ToolUsed | null {
};
}
/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend
* may send a bare name or a `{name}` object; both reduce to the name. */
function parseAwakenTool(v: unknown): string | undefined {
if (typeof v === "string") return v || undefined;
const r = toRecord(v);
if (r && typeof r.name === "string" && r.name) return r.name;
return undefined;
}
/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used
* frame these carry `id` (provider block id) rather than a guaranteed `name`,
* so we label the card by id when no name is present. */
@@ -158,6 +167,10 @@ function dispatch(ev: { type: string; data: unknown }): void {
}
case "stream_end": {
s.endStream();
// The assistant reply stopped streaming, so the immediate turn is over:
// close the busy/pending window so the badge does not stick. If the backend
// keeps a task/subagent running it re-emits session_busy (design/15 §5).
s.setSessionBusy(false, 0);
if (r && typeof r.stopReason === "string") {
s.setContext({ lastAgentMode: s.context.lastAgentMode });
}
@@ -293,6 +306,27 @@ function dispatch(ev: { type: string; data: unknown }): void {
}
break;
}
case "capacity_awaken": {
// design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。
const fromState =
typeof r?.fromState === "string"
? (r.fromState as AwakenState)
: undefined;
const toState =
(typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ??
"quiet";
s.setAwaken(fromState, toState, parseAwakenTool(r?.tool));
break;
}
case "session_busy":
case "session_pending": {
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。
const busy = typeof r?.busy === "boolean" ? r.busy : true;
const pendingCount =
typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount;
s.setSessionBusy(busy, pendingCount);
break;
}
default: {
// Unknown future event: keep it in the context log so no frame is lost.
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
+24 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
@@ -7,6 +7,7 @@ import { MessageBubble } from "../components/chat";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
import { Icon } from "../components/Icon";
import { SessionStatusHint } from "../components/status";
/**
* The single shared page (design/07 §1). All behavior modes live in ONE message
@@ -19,6 +20,16 @@ export function ChatShell(): React.ReactNode {
const { send, sending } = useSendChat();
const bottomRef = useRef<HTMLDivElement>(null);
// The latest assistant message is the one being streamed right now; only it
// carries the live 唤醒路径 node sequence (WakePath), so the capacity state
// reflects the current turn rather than a collapsed historical message.
const lastAssistantId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") return messages[i].id;
}
return undefined;
}, [messages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
@@ -27,6 +38,7 @@ export function ChatShell(): React.ReactNode {
<div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
<SessionStatusHint />
{messages.length === 0 ? (
<EmptyState
icon={<Icon name="chat" size={24} />}
@@ -34,7 +46,17 @@ export function ChatShell(): React.ReactNode {
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
/>
) : (
messages.map((m) => <MessageBubble key={m.id} message={m} />)
messages.map((m) => (
<MessageBubble
key={m.id}
message={m}
active={
m.id === lastAssistantId &&
m.role === "assistant" &&
(m.streaming ?? false)
}
/>
))
)}
<div ref={bottomRef} />
</div>
+35
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import type {
AgentInfo,
AwakenState,
BackthinkItem,
ChatMessage,
FlowGraph,
@@ -27,6 +28,13 @@ export interface AppStore {
backthink: BackthinkItem[];
flowGraph: FlowGraph | null;
deviceUpdates: string[];
/** 唤醒式能力三态(design/14 §5)——当前唤醒状态、上次切换的来源态、最近点亮的工具。 */
awakenState: AwakenState;
awakenFrom: AwakenState | null;
awakenTool: string | null;
/** 会话编排(design/15 §5)——她是否在忙 + 挂起消息数(不阻塞输入框)。 */
sessionBusy: boolean;
pendingCount: number;
setWsStatus: (status: WsStatus) => void;
setSessionId: (id: string) => void;
@@ -47,6 +55,12 @@ export interface AppStore {
markBackthinkPushed: (id: string) => void;
setFlowGraph: (graph: FlowGraph) => void;
addDeviceUpdate: (text: string) => void;
setAwaken: (
from: AwakenState | undefined,
to: AwakenState,
tool?: string | null
) => void;
setSessionBusy: (busy: boolean, pendingCount?: number) => void;
taskLogs: Record<string, string>;
appendTaskLog: (taskId: string, text: string) => void;
@@ -69,6 +83,11 @@ export const useAppStore = create<AppStore>()(
backthink: [],
flowGraph: null,
deviceUpdates: [],
awakenState: "quiet",
awakenFrom: null,
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
taskLogs: {},
setWsStatus: (status) => set({ wsStatus: status }),
@@ -181,6 +200,17 @@ export const useAppStore = create<AppStore>()(
setFlowGraph: (graph) => set({ flowGraph: graph }),
addDeviceUpdate: (text) =>
set((s) => ({ deviceUpdates: [text, ...s.deviceUpdates].slice(0, 50) })),
setAwaken: (from, to, tool) =>
set((s) => ({
awakenState: to,
awakenFrom: from ?? s.awakenFrom,
awakenTool: tool ?? s.awakenTool,
})),
setSessionBusy: (busy, pendingCount) =>
set((s) => ({
sessionBusy: busy,
pendingCount: pendingCount ?? s.pendingCount,
})),
appendTaskLog: (taskId, text) =>
set((s) => ({
@@ -203,6 +233,11 @@ export const useAppStore = create<AppStore>()(
mood: null,
flowGraph: null,
backthink: s.backthink,
awakenState: "quiet",
awakenFrom: null,
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
})),
}),
{
+18
View File
@@ -135,3 +135,21 @@ textarea {
.mu-blink {
animation: mu-blink 1.2s ease-in-out infinite;
}
/* 唤醒上升(静息 → 唤醒 → 全能)的短暂光芒:弹簧过冲 + 脉动光晕。
仅用于 capacity_awaken 状态徽标 / 唤醒路径当前节点,避免常态动画。 */
@keyframes mu-awaken {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--mu-accent) 0%, transparent);
}
40% {
transform: scale(1.05);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--mu-accent) 32%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--mu-accent) 0%, transparent);
}
}
.mu-awaken {
animation: mu-awaken 1.2s var(--mu-spring) ease-in-out;
}
+26
View File
@@ -72,6 +72,26 @@ func (k *APIKey) Revoke(key string) {
delete(k.keys, key)
}
// RevokeByMask removes every key whose masked identifier (the same derived id
// List returns) matches mask. It returns true when at least one key was revoked.
// The mask is lossy, so it is matched by recomputing it for each key; the table
// is small so a linear scan is fine. An empty or nil-returning call is a no-op.
func (k *APIKey) RevokeByMask(mask string) bool {
if mask == "" {
return false
}
k.mu.Lock()
defer k.mu.Unlock()
revoked := false
for key := range k.keys {
if maskKey(key) == mask {
delete(k.keys, key)
revoked = true
}
}
return revoked
}
// List returns a summary of every currently registered API key for the admin
// panel. Key material is never returned; each identifier is masked so the panel
// can tell keys apart without exposing the secret. The in-memory store keeps no
@@ -86,6 +106,12 @@ func (k *APIKey) List() []UserSummary {
return out
}
// MaskKey renders a key's leading and trailing groups so the panel can identify
// an entry without revealing the secret. Short keys are fully hidden. It is the
// exported form of maskKey so callers (e.g. the admin issue handler) can return
// the same masked id the List page shows.
func MaskKey(key string) string { return maskKey(key) }
// maskKey renders a key's leading and trailing groups so the panel can identify
// an entry without revealing the secret. Short keys are fully hidden.
func maskKey(key string) string {
+21
View File
@@ -32,6 +32,7 @@ type Config struct {
WSHub WSHubConfig
ExecSandbox ExecSandbox
MCP MCPConfig
Capacity CapacityConfig
}
// ServerConfig holds the single gateway listener settings.
@@ -123,6 +124,26 @@ type MCPConfig struct {
ServersJSON string `env:"MCP_SERVERS_JSON"`
}
// CapacityConfig configures the awakened-capacity decision layer
// (DevDocs/design/14-唤醒式能力渐进.md §4). It is opt-in: the loop exposes every
// tool until Enabled is true.
type CapacityConfig struct {
// Enabled turns on the capacity decider. When false the loop advertises every
// registered tool (pre-existing behavior). Note: it is wired at the loop
// periphery, not back-end (Invoke stays unblocked).
Enabled bool `env:"CAPACITY_ENABLED" default:"false"`
// MinConfidence is the wake threshold below which the state is forced toward
// the persona default (设计 §2.3 默认保守). Zero selects the code default 0.6.
MinConfidence float64 `env:"CAPACITY_MIN_CONFIDENCE" default:"0.6"`
// ToolSetsJSON, when non-empty, is a JSON map of state→[]tool name, e.g.
// {"quiet":["terminal","file"],"awake":["terminal","file","web-search"],
// "full":null}. A missing state selects the built-in default set; null ("full")
// means unrestricted. Parsed by the capacity wiring at startup (the
// reflection-based loader only supports scalars and []string), matching the
// MCP config pattern.
ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"`
}
// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or
// an SSE endpoint (URL). Enabled gates activation at startup.
type MCPServer struct {
+32
View File
@@ -130,6 +130,38 @@ func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
}
}
func TestLoad_CapacityDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = true, want false (default, opt-in)")
}
if cfg.Capacity.MinConfidence != 0.6 {
t.Errorf("Capacity.MinConfidence = %v, want 0.6", cfg.Capacity.MinConfidence)
}
}
func TestLoad_CapacityEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("CAPACITY_ENABLED", "true")
t.Setenv("CAPACITY_MIN_CONFIDENCE", "0.75")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = false, want true")
}
if cfg.Capacity.MinConfidence != 0.75 {
t.Errorf("Capacity.MinConfidence = %v, want 0.75", cfg.Capacity.MinConfidence)
}
}
func TestParseDotEnv_QuotedAndExport(t *testing.T) {
path := writeEnvFile(t, `export FOO=bar
BAZ='quoted value'
+15
View File
@@ -74,6 +74,8 @@ func setField(fv reflect.Value, raw string) error {
return setBool(fv, raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return setInt(fv, raw)
case reflect.Float32, reflect.Float64:
return setFloat(fv, raw)
case reflect.Slice:
return setStringSlice(fv, raw)
default:
@@ -107,6 +109,19 @@ func setInt(fv reflect.Value, raw string) error {
return nil
}
func setFloat(fv reflect.Value, raw string) error {
if raw == "" {
fv.SetFloat(0)
return nil
}
n, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("invalid float %q", raw)
}
fv.SetFloat(n)
return nil
}
func setStringSlice(fv reflect.Value, raw string) error {
if fv.Type().Elem().Kind() != reflect.String {
return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind())
+62
View File
@@ -89,6 +89,68 @@ func (r *Registry) Persona(id string) (*loader.Persona, bool) {
return p, ok
}
// UpdatePersona replaces the persona registered under p.ID with p, rebuilding
// its emotion machine and affection tracker. It fails when p is nil, has no id,
// or is not currently registered. In-flight loops bound to the previous
// *loader.Persona keep their pointer; only future lookups observe the new value.
func (r *Registry) UpdatePersona(p *loader.Persona) error {
if p == nil {
return fmt.Errorf("registry: nil persona")
}
if p.ID == "" {
return fmt.Errorf("registry: persona id required")
}
machine, err := p.NewMachine()
if err != nil {
return fmt.Errorf("registry: persona %q machine: %w", p.ID, err)
}
affection, err := p.NewAffection()
if err != nil {
return fmt.Errorf("registry: persona %q affection: %w", p.ID, err)
}
r.mu.Lock()
if _, ok := r.personas[p.ID]; !ok {
r.mu.Unlock()
return fmt.Errorf("registry: persona %q not registered", p.ID)
}
r.personas[p.ID] = p
r.mu.Unlock()
r.emotions.Set(p.ID, machine, affection)
return nil
}
// UpdateCharacter replaces the fields of the character registered under c.ID,
// leaving its Default flag intact unless c re-declares it. It fails when c is
// nil, has no id, is not registered, or its persona does not exist.
func (r *Registry) UpdateCharacter(c *Character) error {
if c == nil {
return fmt.Errorf("registry: nil character")
}
if c.ID == "" {
return fmt.Errorf("registry: character id required")
}
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.characters[c.ID]
if !ok {
return fmt.Errorf("registry: character %q not registered", c.ID)
}
if _, ok := r.personas[c.PersonaID]; !ok {
return fmt.Errorf("registry: character %q references unknown persona %q", c.ID, c.PersonaID)
}
existing.Name = c.Name
existing.PersonaID = c.PersonaID
existing.Modes = append(existing.Modes[:0], c.Modes...)
if c.Default {
existing.Default = true
r.defaultID = c.ID
}
return nil
}
// RegisterCharacter registers c. It requires c's persona to exist, so persona
// registration must happen first. A character marked Default becomes the
// library's default role.
+328
View File
@@ -0,0 +1,328 @@
// Package capacity implements the "awakened capacity" decision layer
// (DevDocs/design/14-唤醒式能力渐进.md). It sits outside the loop core: every
// round, it classifies the conversation into one of three states — quiet /
// awake / full — and yields the set of tools that this round may expose to the
// model. The loop then advertises only that allowed set to the model (a view
// over the dispatcher, never a hard boundary: Invoke is left unblocked so an
// emergency call still works — capacity is a decision, not a capability wall).
//
// Intent is judged in layers and defaults to conservative (低置信→静息): L1
// fast heuristics (strong action signals → awake/full, clear companion signals →
// quiet), falling to L2 context trend (recent turns), then L4 learned-habit
// alignment (recalled user habits correct/fill the intent — design §2.2, §2.4),
// before the persona awakening override (design §3). It never uses intent for
// identity — identity stays on channel actor metadata (project red line).
package capacity
import (
"context"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// State is one of the three awakened-capacity states (design §1).
type State string
const (
// StateQuiet is the default: 静·陪伴. Only a narrow, companion-safe tool face
// is exposed (terminal + file edit + memory/emotion companion).
StateQuiet State = "quiet"
// StateAwake is 醒·办事: a real task is detected and tools wake one by one
// (web retrieval, file, terminal).
StateAwake State = "awake"
// StateFull is 全·复杂任务: the complete capability window.
StateFull State = "full"
)
// EventType labels a state-change event.
const EventAwaken = "capacity_awaken"
// Event describes a visible capacity transition (design §1/§4): a state changed
// and a tool was lit up, surfaced to the frontend. A change to quiet does not
// emit (only awakenings are surfaced).
type Event struct {
Type string
FromState State
ToState State
// Tool is the tool that this transition lit up (点亮工具). Empty when no
// single tool can be named.
Tool string
At time.Time
}
// IsAwaken reports whether the event is a capacity_awaken transition.
func (e Event) IsAwaken() bool { return e.Type == EventAwaken }
// Decision is the outcome of one Decide call (design §4). Judgment is the raw
// intent-layer classification (before any persona override), suitable for write-back
// into memory so the L4 layer learns the user's task-expression habits (design §2.4).
type Decision struct {
State State
AllowedTools []string
Events []Event
Confidence float64
Judgment Judgment
}
// Awakening is the persona-level override block (design §3,
// configs/personas/*.yaml → awakening). It is per-persona; the zero value means
// "no override" so the policy falls back to conservative defaults.
type Awakening struct {
// DefaultState is the persona's resting tendency (default_state) used when
// the intent layers are ambiguous. Empty = quiet.
DefaultState State
// MinConfidence overrides the global wake threshold; a positive value wins
// over Config.MinConfidence. Below it, the intent is forced toward the
// persona default (conservative, 不打扰).
MinConfidence float64
// ForceState, when set, unconditionally pins a state (force_state), e.g. a
// "干练助理" persona defaults to awake regardless of intent.
ForceState State
}
// IsZero reports whether no override is configured.
func (a Awakening) IsZero() bool {
return a.DefaultState == "" && a.MinConfidence == 0 && a.ForceState == ""
}
// Config configures a Decider. A zero value disables capacity (all tools always
// surface), so the loop's pre-existing behavior is preserved until enabled.
type Config struct {
// Enabled gates the whole decision layer. When false Decide returns every
// tool and emits no events.
Enabled bool
// MinConfidence is the wake threshold below which the state is forced toward
// the persona default (设计 §2.3 默认保守). Zero selects the default 0.6.
MinConfidence float64
// ToolSets maps a State to the allowed tool names. A missing entry selects
// the built-in default set for that state; a nil entry means "no
// restriction" (all tools); an empty non-nil slice means "no tools". StateFull
// maps to nil (complete window) unless overridden.
ToolSets map[State][]string
// Awakening carries the persona-level override (design §3). Zero = no
// override.
Awakening Awakening
}
// Input is everything Decide needs to classify one round (design §4).
type Input struct {
// Messages is the conversation text in chronological order (oldest first);
// the last element is the current turn. Text is extracted by the caller so
// the capacity package stays decoupled from the model message type.
Messages []string
// CurrentState is the state the agent occupied at the start of this round,
// used purely to emit the FromState of a transition. An empty value is
// treated as StateQuiet.
CurrentState State
// Awakening, when non-zero, overrides the Config.Awakening for this call
// (per-persona override).
Awakening Awakening
// AllToolNames is the full set of known tool-spec names the dispatcher can
// advertise; the allowed set is derived by intersecting the state's set
// with it (preserving AllToolNames order).
AllToolNames []string
// Recall carries the owner's recalled habit memories for this round (design
// §2.2 L4), filled by the caller from Retriever.Retrieve. L4 uses it to correct
// or fill in the intent with learned user habits. Nil or empty keeps L4 silent,
// so L1/L2 behavior is unchanged (向后兼容).
Recall []RecallItem
}
// Option configures a Decider at construction time.
type Option func(*Decider)
// Decider is the awakened-capacity decision engine. It is immutable after New
// and safe for concurrent Decide calls.
type Decider struct {
cfg Config
log *slog.Logger
}
// sourcedLog returns a logger tagged with the module source (project rule §10),
// replacing a nil logger with a discard one so decide can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(discard{}, nil))
}
return logger.WithSource(l, "runtime/capacity")
}
// discard is an io.Writer that drops everything; it sits behind a nil logger so
// no log dependency is forced on a caller that never logs.
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
// WithLogger injects the base logger. Nil is replaced with a discard logger
// tagged with source "runtime/capacity".
func WithLogger(l *slog.Logger) Option {
return func(d *Decider) { d.log = sourcedLog(l) }
}
// New builds a Decider from cfg, applying opts in order. It applies the
// default MinConfidence of 0.6 when the config leaves it at zero.
func New(cfg Config, opts ...Option) *Decider {
if cfg.MinConfidence <= 0 {
cfg.MinConfidence = DefaultMinConfidence
}
d := &Decider{cfg: cfg}
d.log = sourcedLog(nil)
for _, opt := range opts {
if opt != nil {
opt(d)
}
}
return d
}
// DefaultMinConfidence is the conservative wake threshold (design §2.3).
const DefaultMinConfidence = 0.6
// Enabled reports whether the decision layer is active.
func (d *Decider) Enabled() bool { return d.cfg.Enabled }
// Decide classifies one round and returns the state, allowed tool set and any
// transition events. When capacity is disabled it returns the full tool set and
// no events, preserving the loop's default behavior.
func (d *Decider) Decide(ctx context.Context, in Input) Decision {
if !d.cfg.Enabled {
return Decision{State: in.CurrentState, AllowedTools: append([]string(nil), in.AllToolNames...)}
}
state, conf, tool := d.intent(in)
j := judgmentFor(in, state, conf, tool)
state, conf = d.applyAwakening(state, conf, in)
allowed := d.allowedTools(state, in.AllToolNames)
events := d.stateEvents(effectiveState(in.CurrentState), state, tool, allowed)
d.log.Debug("capacity decide",
"from", effectiveState(in.CurrentState), "to", state,
"confidence", conf, "allowed", len(allowed), "tool", tool,
"is_task", j.IsTask, "task_kind", j.TaskKind)
return Decision{State: state, AllowedTools: allowed, Events: events, Confidence: conf, Judgment: j}
}
// effectiveAwakening returns the per-call Awakening when set, else the config's.
func (d *Decider) effectiveAwakening(in Input) Awakening {
if !in.Awakening.IsZero() {
return in.Awakening
}
return d.cfg.Awakening
}
// applyAwakening overlays the persona override (design §3, B) on the intent
// layer result (A): force_state pins unconditionally; otherwise a result below
// the effective threshold falls back to the persona default (conservative).
func (d *Decider) applyAwakening(state State, conf float64, in Input) (State, float64) {
aw := d.effectiveAwakening(in)
if aw.ForceState != "" {
return aw.ForceState, conf
}
minConf := d.cfg.MinConfidence
if aw.MinConfidence > 0 {
minConf = aw.MinConfidence
}
if conf < minConf || state == "" {
if def := aw.DefaultState; def != "" {
return def, conf
}
return StateQuiet, conf
}
return state, conf
}
// allowedTools returns the state's allowed set intersected with the known tool
// names, preserving AllToolNames order. StateFull (and any state whose set is
// nil) is unrestricted and returns the full tool list.
func (d *Decider) allowedTools(state State, all []string) []string {
set := d.toolSetFor(effectiveState(state))
if set == nil {
return all
}
return intersectOrdered(set, all)
}
// toolSetFor returns the configured or built-in allowed set for state. A nil
// return means unrestricted (all tools).
func (d *Decider) toolSetFor(state State) []string {
if ts, ok := d.cfg.ToolSets[state]; ok {
if ts == nil {
return nil
}
return ts
}
switch state {
case StateQuiet:
return defaultQuietTools()
case StateAwake:
return defaultAwakeTools()
default:
return nil // StateFull → complete window
}
}
// stateEvents builds the transition event when the state actually changed to a
// more-awake state. tool is the lit tool, taken from the intent or, when empty,
// the first allowed tool in the target state's canonical set (deterministic). A
// change to quiet emits nothing.
func (d *Decider) stateEvents(from, to State, tool string, toAllowed []string) []Event {
if from == to || to == StateQuiet {
return nil
}
if tool == "" {
tool = firstAllowedCanonical(d.toolSetFor(to), toAllowed)
}
return []Event{{Type: EventAwaken, FromState: from, ToState: to, Tool: tool, At: time.Now()}}
}
// firstAllowedCanonical returns the first tool in the canonical state set that is
// actually allowed (present in allowed). An unrestricted set (nil canonical)
// falls back to the first tool of allowed. It is deterministic because the
// canonical sets use a fixed order, unlike the registry's map iteration order.
func firstAllowedCanonical(canonical, allowed []string) string {
if len(canonical) == 0 {
if len(allowed) > 0 {
return allowed[0]
}
return ""
}
allowedSet := make(map[string]bool, len(allowed))
for _, n := range allowed {
allowedSet[n] = true
}
for _, n := range canonical {
if allowedSet[n] {
return n
}
}
return ""
}
// intersectOrdered keeps the tools of all that are in allow, preserving the
// all order so the advertised schema list stays stable.
func intersectOrdered(allow, all []string) []string {
if len(allow) == 0 || len(all) == 0 {
return []string{}
}
allowSet := make(map[string]bool, len(allow))
for _, a := range allow {
allowSet[a] = true
}
out := make([]string, 0, len(all))
for _, name := range all {
if allowSet[name] {
out = append(out, name)
}
}
return out
}
// effectiveState returns StateQuiet for an empty state, so the CurrentState of
// a nil input is always a valid resting state.
func effectiveState(s State) State {
if s == "" {
return StateQuiet
}
return s
}
+234
View File
@@ -0,0 +1,234 @@
package capacity
import (
"context"
"reflect"
"testing"
)
// allTools is a representative registration-order tool list for tests; it covers
// the basic/io/terminal plugins actually shipped by the repo.
func allTools() []string {
return []string{
"terminal", "file", "knowledge", "text",
"web-search", "web-fetch", "http",
"calculator", "datetime", "random", "json", "markdown",
}
}
func activeConfig() Config {
return Config{Enabled: true, MinConfidence: 0.6}
}
func in(messages ...string) Input {
return Input{Messages: messages, CurrentState: StateQuiet, AllToolNames: allTools()}
}
func contains(s []string, want string) bool {
for _, v := range s {
if v == want {
return true
}
}
return false
}
func TestDecide_Disabled_ReturnsAllToolsNoEvents(t *testing.T) {
d := New(Config{}) // Enabled zero → disabled
dec := d.Decide(context.Background(), in("帮我查一下资料"))
if !reflect.DeepEqual(dec.AllowedTools, allTools()) {
t.Fatalf("disabled allowed = %v, want all %v", dec.AllowedTools, allTools())
}
if len(dec.Events) != 0 {
t.Fatalf("disabled should emit no events, got %+v", dec.Events)
}
}
func TestDecide_Chitchat_StaysQuiet(t *testing.T) {
d := New(activeConfig())
for _, msg := range []string{"你好", "抱抱", "嗯嗯", "晚安", "今天好累呀"} {
if got := d.Decide(context.Background(), in(msg)); got.State != StateQuiet {
t.Errorf("chitchat %q woke to %s, want quiet", msg, got.State)
}
}
}
func TestDecide_Chitchat_NoAwakenEvent(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("你好"))
if len(dec.Events) != 0 {
t.Fatalf("quiet chat should not emit capacity_awaken, got %+v", dec.Events)
}
}
func TestDecide_Query_Awakens(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("帮我查一下今天的天气"))
if dec.State != StateAwake {
t.Fatalf("query state = %s, want awake", dec.State)
}
if dec.Confidence < 0.6 {
t.Fatalf("query confidence = %v, want >= 0.6", dec.Confidence)
}
if !contains(dec.AllowedTools, "web-search") {
t.Fatalf("awake allowed should include web-search, got %v", dec.AllowedTools)
}
if len(dec.Events) != 1 || dec.Events[0].FromState != StateQuiet || dec.Events[0].ToState != StateAwake {
t.Fatalf("expected a quiet→awake event, got %+v", dec.Events)
}
}
func TestDecide_EditFile_Awakens(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("改一下 cx.go 这个函数"))
if dec.State != StateAwake {
t.Fatalf("edit state = %s, want awake", dec.State)
}
if !contains(dec.AllowedTools, "file") {
t.Fatalf("awake allowed should include file, got %v", dec.AllowedTools)
}
if len(dec.Events) != 1 || dec.Events[0].Tool != "file" {
t.Fatalf("edit event should light up file, got %+v", dec.Events)
}
}
func TestDecide_ComplexTask_Full(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("帮我重构整个项目,迁移到新架构"))
if dec.State != StateFull {
t.Fatalf("complex state = %s, want full", dec.State)
}
if !reflect.DeepEqual(dec.AllowedTools, allTools()) {
t.Fatalf("full allowed = %v, want all %v", dec.AllowedTools, allTools())
}
}
func TestDecide_L1_UndecidedDeferstoL2_WakesOnTaskTrend(t *testing.T) {
d := New(activeConfig())
// Last turn (这个) is undecided by L1; the recent history is task-leaning.
dec := d.Decide(context.Background(), in("帮我查一下资料", "还有吗", "这个"))
if dec.State != StateAwake {
t.Fatalf("task-trend state = %s, want awake", dec.State)
}
}
func TestDecide_L2_CompanionTrend_Quiet(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("你好", "抱抱", "嗯嗯", "这个"))
if dec.State != StateQuiet {
t.Fatalf("companion-trend state = %s, want quiet", dec.State)
}
}
func TestDecide_L2_Followup_Wakes(t *testing.T) {
d := New(activeConfig())
// A clear previous task plus a bare continuation cue (继续) stays awake —
// L2 picks up the follow-up on the previous round's task (design §2.2).
dec := d.Decide(context.Background(), in("帮我查一下资料", "继续"))
if dec.State != StateAwake {
t.Fatalf("followup state = %s, want awake", dec.State)
}
}
func TestDecide_Undecided_ConservativeQuiet(t *testing.T) {
d := New(activeConfig())
// Entirely neutral turns with no signal at all.
dec := d.Decide(context.Background(), in("嗯", "这个", "然后"))
if dec.State != StateQuiet {
t.Fatalf("undecided state = %s, want quiet (conservative)", dec.State)
}
}
func TestDecide_PersonaForceState(t *testing.T) {
d := New(activeConfig())
inp := in("你好")
inp.Awakening = Awakening{ForceState: StateAwake}
dec := d.Decide(context.Background(), inp)
if dec.State != StateAwake {
t.Fatalf("force_state state = %s, want awake", dec.State)
}
}
func TestDecide_PersonaDefaultState(t *testing.T) {
// An assistant-type persona rests at awake; an undecided round lands there.
d := New(Config{Enabled: true, MinConfidence: 0.6, Awakening: Awakening{DefaultState: StateAwake}})
dec := d.Decide(context.Background(), in("这个"))
if dec.State != StateAwake {
t.Fatalf("assistant default_state = %s, want awake", dec.State)
}
}
func TestDecide_PersonaMinConfidence_ForcesQuiet(t *testing.T) {
// A high threshold forces even a strong task signal to stay quiet. Low conf.
d := New(Config{Enabled: true, MinConfidence: 0.6})
inp := in("帮我查一下资料")
inp.Awakening = Awakening{MinConfidence: 0.99}
if dec := d.Decide(context.Background(), inp); dec.State != StateQuiet {
t.Fatalf("high min_confidence should force quiet, got %s", dec.State)
}
// The default threshold lets the same strong signal wake.
if dec := d.Decide(context.Background(), in("帮我查一下资料")); dec.State != StateAwake {
t.Fatalf("default min_confidence should wake, got %s", dec.State)
}
}
func TestDecide_AllowedSets(t *testing.T) {
d := New(activeConfig())
quiet := d.Decide(context.Background(), in("你好")).AllowedTools
if !reflect.DeepEqual(quiet, []string{"terminal", "file", "knowledge", "text"}) {
t.Fatalf("quiet allowed = %v", quiet)
}
awake := d.Decide(context.Background(), in("帮我查一下资料")).AllowedTools
wantAwake := []string{"terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http"}
if !reflect.DeepEqual(awake, wantAwake) {
t.Fatalf("awake allowed = %v, want %v", awake, wantAwake)
}
if contains(awake, "json") || contains(awake, "markdown") || contains(awake, "calculator") {
t.Fatalf("awake must not expose heavy utility tools, got %v", awake)
}
full := d.Decide(context.Background(), in("帮我重构整个项目")).AllowedTools
if !reflect.DeepEqual(full, allTools()) {
t.Fatalf("full allowed = %v, want all %v", full, allTools())
}
}
func TestDecide_Event_LitToolIsAllowed(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), in("帮我查一下资料"))
if len(dec.Events) != 1 {
t.Fatalf("expected one event, got %+v", dec.Events)
}
ev := dec.Events[0]
if !ev.IsAwaken() || ev.Tool == "" {
t.Fatalf("event should be an awaken with a lit tool, got %+v", ev)
}
if !contains(dec.AllowedTools, ev.Tool) {
t.Fatalf("lit tool %q not in allowed set %v", ev.Tool, dec.AllowedTools)
}
}
func TestDecide_CustomToolSets(t *testing.T) {
d := New(Config{
Enabled: true,
MinConfidence: 0.6,
ToolSets: map[State][]string{StateQuiet: {"terminal"}},
})
dec := d.Decide(context.Background(), in("你好"))
if !reflect.DeepEqual(dec.AllowedTools, []string{"terminal"}) {
t.Fatalf("custom quiet allowed = %v, want [terminal]", dec.AllowedTools)
}
}
func TestDecide_NilToolNames_EmptyAllowed(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), Input{Messages: []string{"你好"}, CurrentState: StateQuiet})
if dec.State != StateQuiet {
t.Fatalf("state = %s, want quiet", dec.State)
}
if len(dec.AllowedTools) != 0 {
t.Fatalf("allowed with no known tools = %v, want empty", dec.AllowedTools)
}
}
+325
View File
@@ -0,0 +1,325 @@
package capacity
import (
"math"
"strconv"
"strings"
)
// RecallItem is one recalled memory hit handed to the decider for L4 habit
// alignment (design §2.2 L4). It is a decoupling view over memory.Result so the
// capacity package stays independent of the memory subsystem; the loop builds it
// from Retriever.Retrieve. Scope carries the memory scope value ("user" / "persona"
// / "project") so L4 can honor the isolation red line (学行为习惯, 不读陌生人敏感).
type RecallItem struct {
Content string
Scope string
Kind string
Tags []string
Score float64
}
// Judgment is the intent outcome of one round, meant to be written back into
// memory so the L4 layer learns the user's task-expression habits over time
// (design §2.4 闭环学习). It records the raw intent-layer classification (before
// any persona override) because the closed loop learns the user's intent pattern,
// not the persona's presentation. Text is the user expression that was judged — the
// habit key L4 re-matches on later rounds.
type Judgment struct {
IsTask bool
TaskKind string
State State
Confidence float64
Text string
}
// L4 tuning constants.
const (
// minHabitOverlap is the minimum longest-common-substring length (in runes) a
// recalled habit must share with the current turn before L4 treats it as a
// matching habit. 3 runes covers a short Chinese phrase (搞一下) while staying
// conservative against token noise.
minHabitOverlap = 3
// taskHabitConfidence is the wake confidence L4 assigns when it matches a
// habit-style memory (not a canonical intent record) that leans 办事. It sits
// above the default wake threshold so the habit can actually wake a missed task.
taskHabitConfidence = 0.7
// intent kind tokens stored in an intent record and understood by awakenVerdict.
taskKindComplex = "complex"
taskKindCode = "code"
taskKindQuery = "query"
taskKindOps = "ops"
// intentRecordPrefix marks a canonical intent record written back by RecordIntent.
intentRecordPrefix = "intent:"
)
// RecordIntent formats a judgment into the canonical intent-record string written
// back to memory (design §2.4). Only task judgments are worth learning — a quiet /
// non-task judgment returns "" so callers can skip persisting noise. The format is
// machine-parseable by parseIntentRecord, which L4 re-reads on later rounds to
// close the learning loop. The user expression is written in plain text so the
// keyword retriever can still surface it against a later similar turn.
func (d *Decider) RecordIntent(j Judgment) string {
if !j.IsTask {
return ""
}
return formatIntentRecord(j)
}
// formatIntentRecord encodes j as one parseable line. The text field comes last and
// is taken verbatim (it may contain ';' or '='), so parseIntentRecord reads the
// fixed prefix and keeps the remainder as the expression.
func formatIntentRecord(j Judgment) string {
return intentRecordPrefix +
"isTask=" + strconv.FormatBool(j.IsTask) +
";taskKind=" + j.TaskKind +
";confidence=" + strconv.FormatFloat(j.Confidence, 'f', -1, 64) +
";text=" + j.Text
}
// intentRecord is the parsed form of a canonical intent record.
type intentRecord struct {
isTask bool
taskKind string
confidence float64
text string
}
// parseIntentRecord decodes a canonical intent record written by formatIntentRecord.
// ok is false when content is not a well-formed intent record (missing isTask).
func parseIntentRecord(content string) (intentRecord, bool) {
rest, ok := strings.CutPrefix(content, intentRecordPrefix)
if !ok {
return intentRecord{}, false
}
// text is last and taken verbatim, so a ';' or '=' inside it cannot split it.
head, text := rest, ""
if idx := strings.Index(rest, ";text="); idx >= 0 {
head = rest[:idx]
text = rest[idx+len(";text="):]
}
rec := intentRecord{text: text}
seenTask := false
for _, f := range strings.Split(head, ";") {
k, v, cut := strings.Cut(f, "=")
if !cut {
continue
}
switch k {
case "isTask":
rec.isTask = v == "true"
seenTask = true
case "taskKind":
rec.taskKind = v
case "confidence":
if x, err := strconv.ParseFloat(v, 64); err == nil {
rec.confidence = x
}
}
}
if !seenTask {
return intentRecord{}, false
}
return rec, true
}
// l4 is the L4 habit-alignment layer (design §2.2, §2.4): it uses recalled user
// habits to correct or fill in the intent that L1/L2 could not settle. It returns
// ok=false when no habit applies, so the caller's L1/L2 verdict (or the
// conservative fallback) stands — L4 is silent unless a learned habit matches
// (向后兼容, 数据缺失时回退).
func (d *Decider) l4(current string, recall []RecallItem) (State, float64, string, bool) {
text := strings.TrimSpace(current)
if text == "" || len(recall) == 0 {
return "", 0, "", false
}
bestState, bestConf, bestTool := State(""), 0.0, ""
matched := false
for _, it := range recall {
if !allowedHabitScope(it.Scope) {
continue
}
if rec, ok := parseIntentRecord(it.Content); ok {
// A canonical record written back by RecordIntent: it knows the exact
// task kind and confidence. Only a task record with a matching expression
// testifies to task; the stored text is plaintext so overlap is matched
// against it (tolerating paraphrase).
if !rec.isTask || rec.text == "" || overlapLen(text, rec.text) < minHabitOverlap {
continue
}
state, tool := awakenVerdict(rec.taskKind)
conf := clampConf(rec.confidence)
if conf == 0 {
conf = taskHabitConfidence
}
if conf > bestConf {
bestState, bestConf, bestTool, matched = state, conf, tool, true
}
continue
}
// Habit-style memory (not written by us): treat it as a task habit only when
// it leans 办事 and shares a phrase with the current turn, so an unrelated
// preference (coffee habit) never triggers a wake.
if !leansTask(it.Content) || overlapLen(text, it.Content) < minHabitOverlap {
continue
}
conf := taskHabitConfidence
if it.Score > 0 {
conf = clampConf(taskHabitConfidence + it.Score*0.15)
}
state, tool := StateAwake, inferTool(text)
if isComplex(text) {
state = StateFull
}
if conf > bestConf {
bestState, bestConf, bestTool, matched = state, conf, tool, true
}
}
if !matched {
return "", 0, "", false
}
return bestState, bestConf, bestTool, true
}
// mergeL4 combines an L1/L2 verdict with the L4 habit verdict (design §2.2 合并规则).
// It is conservative: a strong learned habit may count a task the heuristics missed
// (quiet → awake, a 举手示意 but never a jump straight to full) or confirm/boost one
// they caught, up to full when the habit says complex; it never drags a clear task
// down to quiet (学行为习惯,不误打断).
func (d *Decider) mergeL4(s State, c float64, t string, l4s State, l4c float64, l4t string, l4ok bool) (State, float64, string) {
if !l4ok || (l4s != StateAwake && l4s != StateFull) {
return s, c, t
}
if s == StateQuiet || s == "" {
// 保守红线:静息→唤醒是"举手示意",即便习惯标记复杂也不直接跳到全能。
return StateAwake, l4c, l4t
}
st := s
if l4s == StateFull {
st = StateFull
}
return st, boostConf(c, l4c), pickTool(t, l4t)
}
// awakenVerdict maps an intent-record task kind back to a state + lit tool.
func awakenVerdict(kind string) (State, string) {
switch kind {
case taskKindComplex:
return StateFull, ""
case taskKindCode:
return StateAwake, "file"
case taskKindQuery:
return StateAwake, "web-search"
case taskKindOps:
return StateAwake, "terminal"
default:
return StateAwake, ""
}
}
// taskKindFor maps a (state, tool) to the intent kind recorded for writeback.
func taskKindFor(state State, tool string) string {
if state == StateFull {
return taskKindComplex
}
switch tool {
case "file":
return taskKindCode
case "web-search", "web-fetch", "http":
return taskKindQuery
case "terminal":
return taskKindOps
default:
return ""
}
}
// allowedHabitScope reports the scopes L4 may read for user habits. ScopeUser is the
// owner's shared habit (primary); ScopePersona / ScopeProject are readable per design
// §2.2; an empty scope (recall without scope metadata) is tolerated. Any other scope
// is refused, so a stranger / owner-private memory can never reach L4 here (design
// §2.2 红线: 学行为习惯, 不读陌生人敏感).
func allowedHabitScope(scope string) bool {
switch scope {
case "", "user", "persona", "project":
return true
default:
return false
}
}
// leansTask reports whether a habit memory is about the user's task-expression
// pattern rather than an unrelated preference, so L4 does not wake on a coffee habit.
func leansTask(content string) bool {
return containsAny(content, []string{
"办事", "任务", "布置", "要办", "让我", "要我", "去做", "来办", "干活", "做工",
"习惯用", "习惯说", "让我做", "叫我",
// ASCII
"task", "asks me to", "want me to", "has me", "habit",
})
}
// overlapLen returns the length (in runes) of the longest common substring of a and
// b — the lexical-overlap measure L4 uses to detect "the user expression this round
// echoes a phrase the memory recorded", tolerating paraphrase (搞一下那个部署 vs
// 搞一下这个部署 share 搞一下).
func overlapLen(a, b string) int {
ar, br := []rune(a), []rune(b)
if len(ar) == 0 || len(br) == 0 {
return 0
}
prev := make([]int, len(br)+1)
best := 0
for i := 1; i <= len(ar); i++ {
cur := make([]int, len(br)+1)
for j := 1; j <= len(br); j++ {
if ar[i-1] == br[j-1] {
cur[j] = prev[j-1] + 1
if cur[j] > best {
best = cur[j]
}
}
}
prev = cur
}
return best
}
// boostConf raises a confirmed task confidence above the stronger of the two sources,
// reflecting corroboration (设计: 越用越准), capped at 1.
func boostConf(a, b float64) float64 { return clampConf(math.Max(a, b) + 0.05) }
// clampConf clamps c to [0,1].
func clampConf(c float64) float64 {
if c < 0 {
return 0
}
if c > 1 {
return 1
}
return c
}
// pickTool prefers the learned habit's lit tool when non-empty, else the caller's.
func pickTool(t, l4t string) string {
if l4t != "" {
return l4t
}
return t
}
// judgmentFor builds the write-back judgment from the raw intent-layer result.
func judgmentFor(in Input, state State, conf float64, tool string) Judgment {
j := Judgment{State: state, Confidence: conf}
if n := len(in.Messages); n > 0 {
j.Text = in.Messages[n-1]
}
j.IsTask = state == StateAwake || state == StateFull
if j.IsTask {
j.TaskKind = taskKindFor(state, tool)
}
return j
}
+177
View File
@@ -0,0 +1,177 @@
package capacity
import (
"context"
"testing"
)
// uRecall builds an Input over messages with the given recall (L4 feed), mirroring
// the in() helper but adding the recall hook.
func inRecall(messages []string, recall []RecallItem) Input {
in := in(messages...)
in.Recall = recall
return in
}
// taskRecord is a canonical intent record written back by RecordIntent, carrying a
// learned task expression for the closed loop.
func taskRecord(expr string, conf float64) string {
return formatIntentRecord(Judgment{IsTask: true, TaskKind: taskKindOps, Confidence: conf, Text: expr})
}
func TestDecide_L4_WakesMissedTask_CanonicalRecord(t *testing.T) {
d := New(activeConfig())
// "部署" is a strong verb but "搞一下那个部署" carries no imperative marker or
// object noun, so L1 is undecided and L2 (single turn) is undecided too. A learned
// habit (canonical record) that "搞一下这个部署" is a task must wake it (L4 fills
// the gap 设计 §2.2).
recall := []RecallItem{{
Content: taskRecord("搞一下这个部署", 0.85),
Scope: "user",
Score: 0.8,
}}
dec := d.Decide(context.Background(), inRecall([]string{"搞一下那个部署"}, recall))
if dec.State != StateAwake {
t.Fatalf("state = %s, want awake (L4 habit learned 搞一下=办事)", dec.State)
}
if !contains(dec.AllowedTools, "terminal") {
t.Fatalf("awake allowed should include terminal (ops habit), got %v", dec.AllowedTools)
}
if dec.Confidence < 0.6 {
t.Fatalf("confidence = %v, want >= 0.6", dec.Confidence)
}
if !dec.Judgment.IsTask {
t.Fatalf("judgment.IsTask = false, want true")
}
}
func TestDecide_L4_WakesMissedTask_HabitMemory(t *testing.T) {
d := New(activeConfig())
// A habit-style memory (not written by RecordIntent) that leans 办事 and shares the
// phrase must also wake, without L1/L2 support.
recall := []RecallItem{{
Content: "用户习惯:他习惯说搞一下就是要我办事",
Scope: "user",
Score: 0.5,
}}
dec := d.Decide(context.Background(), inRecall([]string{"搞一下那个脚本"}, recall))
if dec.State != StateAwake {
t.Fatalf("state = %s, want awake (habit memory leans 办事)", dec.State)
}
}
func TestDecide_L4_BoostsConfidenceOnAwake(t *testing.T) {
d := New(activeConfig())
// L2 already wakes this follow-up at 0.66; a strong learned habit corroborating
// the same task phrasing must raise the confidence (设计: 越用越准).
without := d.Decide(context.Background(), in("帮我查一下资料", "还有吗"))
if without.State != StateAwake || without.Confidence < 0.6 {
t.Fatalf("baseline = state %s conf %v, want awake >= 0.6", without.State, without.Confidence)
}
recall := []RecallItem{{
Content: taskRecord("还有吗", 0.9),
Scope: "user",
}}
with := d.Decide(context.Background(), inRecall([]string{"帮我查一下资料", "还有吗"}, recall))
if with.State != StateAwake {
t.Fatalf("with-recall state = %s, want awake", with.State)
}
if with.Confidence <= without.Confidence {
t.Fatalf("with-recall confidence = %v, want > baseline %v", with.Confidence, without.Confidence)
}
}
func TestDecide_L4_MissingRecall_FallsBackToL1L2(t *testing.T) {
d := New(activeConfig())
// No recall (nil or empty) keeps L1/L2 behavior identical.
base := d.Decide(context.Background(), in("帮我查一下资料"))
nilRecall := d.Decide(context.Background(), inRecall([]string{"帮我查一下资料"}, nil))
emptyRecall := d.Decide(context.Background(), inRecall([]string{"帮我查一下资料"}, []RecallItem{}))
for _, got := range []Decision{nilRecall, emptyRecall} {
if got.State != base.State || got.Confidence != base.Confidence {
t.Fatalf("no-recall = state %s conf %v, want baseline %s %v",
got.State, got.Confidence, base.State, base.Confidence)
}
}
}
func TestDecide_L4_IgnoresNonAllowedScope(t *testing.T) {
d := New(activeConfig())
// A recalled item outside the allowed scopes (stranger / secret) must not feed L4
// even when it leans task and shares the phrase — 只读用户授权范围记忆.
for _, scope := range []string{"stranger", "secret", "group"} {
recall := []RecallItem{{
Content: "用户习惯:他说搞一下就是要我办事",
Scope: scope,
Score: 0.9,
}}
dec := d.Decide(context.Background(), inRecall([]string{"搞一下那个部署"}, recall))
if dec.State != StateQuiet {
t.Fatalf("scope %q leaked into L4: state = %s, want quiet", scope, dec.State)
}
}
}
func TestDecide_L4_NonTaskHabit_StaysConservative(t *testing.T) {
d := New(activeConfig())
// A recalled preference that is not a task habit (coffee) must not wake a neutral
// turn — 默认保守 still holds.
recall := []RecallItem{{
Content: "用户习惯:他喜欢喝冰美式",
Scope: "user",
Score: 0.9,
}}
dec := d.Decide(context.Background(), inRecall([]string{"今天喝冰美式了吗"}, recall))
if dec.State != StateQuiet {
t.Fatalf("non-task habit woke to %s, want quiet", dec.State)
}
}
func TestDecide_L4_UndecidedNoHabit_ConservativeQuiet(t *testing.T) {
d := New(activeConfig())
dec := d.Decide(context.Background(), inRecall([]string{"嗯", "这个", "然后"}, nil))
if dec.State != StateQuiet {
t.Fatalf("undecided state = %s, want quiet (conservative)", dec.State)
}
}
func TestRecordIntent_TaskFormatsParses(t *testing.T) {
d := New(activeConfig())
j := Judgment{IsTask: true, TaskKind: taskKindQuery, Confidence: 0.9, Text: "帮我查一下今天的天气;再跑个测试"}
rec := d.RecordIntent(j)
if rec == "" {
t.Fatal("RecordIntent of a task judgment returned empty")
}
got, ok := parseIntentRecord(rec)
if !ok {
t.Fatalf("parseIntentRecord failed on %q", rec)
}
if !got.isTask || got.taskKind != taskKindQuery || got.text != j.Text {
t.Fatalf("round-trip mismatch: %+v, want isTask=true kind=query text=%q", got, j.Text)
}
if got.confidence != j.Confidence {
t.Fatalf("confidence = %v, want %v", got.confidence, j.Confidence)
}
}
func TestRecordIntent_NonTaskReturnsEmpty(t *testing.T) {
d := New(activeConfig())
if rec := d.RecordIntent(Judgment{IsTask: false, Text: "你好"}); rec != "" {
t.Fatalf("non-task judgment persisted %q, want empty (nothing worth learning)", rec)
}
}
func TestDecide_JudgmentPopulated(t *testing.T) {
d := New(activeConfig())
task := d.Decide(context.Background(), in("帮我查一下资料"))
if !task.Judgment.IsTask || task.Judgment.TaskKind != taskKindQuery {
t.Fatalf("task judgment = %+v, want IsTask=true kind=query", task.Judgment)
}
if task.Judgment.Text != "帮我查一下资料" {
t.Fatalf("judgment text = %q, want the current turn", task.Judgment.Text)
}
quiet := d.Decide(context.Background(), in("你好"))
if quiet.Judgment.IsTask || quiet.Judgment.TaskKind != "" {
t.Fatalf("quiet judgment = %+v, want IsTask=false kind empty", quiet.Judgment)
}
}
+300
View File
@@ -0,0 +1,300 @@
package capacity
import "strings"
// defaultQuietTools is the companion-safe face (design §1): terminal + file edit
// + memory/emotion companion. It is a function building the slice fresh so no
// package-level global slice is introduced (project rule §10).
func defaultQuietTools() []string {
return []string{"terminal", "file", "knowledge", "text"}
}
// defaultAwakeTools extends the quiet face with web/network retrieval (design
// §1 唤醒态): a real task wakes the retrieval tools, file and terminal.
func defaultAwakeTools() []string {
return []string{"terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http"}
}
// intent classifies the round. It runs L1 on the current turn, falls to L2 on
// the recent context trend, and folds in the L4 learned-habit verdict (design §2.2)
// at each layer: L4 may correct a decided result (boost / quiet→awake) or fill in
// when L1/L2 are undecided. With no matching habit L4 is silent, so a turn with no
// recall behaves exactly as before; otherwise the result stays conservative (低置信
// still rests via applyAwakening).
func (d *Decider) intent(in Input) (State, float64, string) {
if len(in.Messages) == 0 {
return "", 0, ""
}
current := in.Messages[len(in.Messages)-1]
l4s, l4c, l4t, l4ok := d.l4(current, in.Recall)
if s, c, t, ok := l1(current); ok {
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
}
if s, c, t, ok := l2(in.Messages); ok {
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
}
if l4ok {
return l4s, l4c, l4t
}
return "", 0.3, "" // 拿不准 → conservative fallback
}
// l1 is the fast heuristic on the current turn (design §2.2 L1): a strong
// action signal wakes (awake, or full for complex work); a clear companion
// signal (weak + no task object) stays quiet; otherwise it is undecided and the
// caller defers to L2.
func l1(current string) (State, float64, string, bool) {
if hasStrongSignal(current) {
if isComplex(current) {
return StateFull, 0.9, inferTool(current), true
}
return StateAwake, 0.9, inferTool(current), true
}
if isClearCompanion(current) {
return StateQuiet, 0.8, "", true
}
return "", 0, "", false
}
// l2 looks at the recent context trend (design §2.2 L2) when L1 is undecided:
// a session firmly leaning task (or a follow-up on the previous task) wakes;
// a session firmly leaning emotional keeps quiet; a balanced window defers.
func l2(messages []string) (State, float64, string, bool) {
window := lastN(messages, 6)
task, comp := 0, 0
lastTask := ""
for _, m := range window {
switch {
case hasStrongSignal(m):
task++
lastTask = m
case isClearCompanion(m):
comp++
case hasFollowup(m):
task++
}
}
if task > comp {
if isComplex(lastTask) {
return StateFull, 0.66, inferTool(lastTask), true
}
return StateAwake, 0.66, inferTool(lastTask), true
}
if comp > task {
return StateQuiet, 0.7, "", true
}
return "", 0, "", false
}
// lastN returns the last n elements of messages (or all when shorter).
func lastN(messages []string, n int) []string {
if len(messages) <= n {
return messages
}
return messages[len(messages)-n:]
}
// hasStrongSignal reports a task-verb present together with either an
// imperative marker (帮我/请/去/把/将) or a concrete task object (file path,
// URL, code, a task noun) — 明确的动作指令 + 任务对象 (design §2.2 L1).
func hasStrongSignal(text string) bool {
if !hasStrongVerb(text) {
return false
}
return hasImperativeMarker(text) || hasTaskObject(text)
}
// hasStrongVerb reports an action verb that implies work (查/改/写/搜/跑/部署…).
func hasStrongVerb(text string) bool {
return containsAny(text, strongVerbs())
}
// strongVerbs is the L1 action-verb vocabulary.
func strongVerbs() []string {
return []string{
"帮我", "查", "查询", "查一下", "搜索", "搜", "搜一下", "查找", "找一下", "找",
"看", "看看", "检查", "核对", "改", "修改", "编辑", "写", "编写", "创建",
"生成", "实现", "配置", "修复", "删除", "移除", "升级", "更新", "搭建",
"部署", "迁移", "重构", "运行", "执行", "跑", "编译", "构建", "安装",
"启动", "重启", "停止", "下载", "上传", "翻译", "计算", "对比", "分析",
"总结", "汇报", "审批", "编排",
// ASCII
"help me", "search", "query", "look up", "find", "edit", "modify",
"write", "create", "generate", "fix", "refactor", "migrate", "deploy",
"build", "run", "compile", "install", "start", "restart", "stop", "test",
"analyze", "calculate", "compare", "please",
}
}
// hasImperativeMarker reports a direct instruction cue (帮我/请/去/把/将/麻烦…).
func hasImperativeMarker(text string) bool {
return containsAny(text, imperativeMarkers())
}
func imperativeMarkers() []string {
return []string{
"帮我", "请", "给我", "麻烦你", "你能", "你可不可以", "去", "把", "将",
"帮忙", "拜托", "帮我一下",
// ASCII
"please", "help me", "can you", "could you", "would you",
}
}
// hasTaskObject reports a concrete task object: a file path, URL, code snippet,
// or an object noun (报告/文档/代码/测试/设备…).
func hasTaskObject(text string) bool {
return hasFileObject(text) || hasURLObject(text) || hasCodeObject(text) || hasTaskNoun(text)
}
// hasFileObject reports a path-like target (a known source extension, a drive
// path, a "./" or "../" relative path, a bare filename such as cx.go).
func hasFileObject(text string) bool {
for _, ext := range fileExtensions() {
if strings.Contains(text, ext) {
return true
}
}
if strings.Contains(text, "./") || strings.Contains(text, "../") {
return true
}
return false
}
// fileExtensions is the source/config extension set used for object detection.
func fileExtensions() []string {
return []string{
".go", ".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".java", ".c", ".cpp",
".h", ".yaml", ".yml", ".json", ".toml", ".ini", ".conf", ".mod", ".sum",
".md", ".txt", ".css", ".html", ".sh", ".sql", ".proto", ".env.example",
}
}
// hasURLObject reports an http(s) URL or a bare web host.
func hasURLObject(text string) bool {
return containsAny(text, []string{"http://", "https://", "www.",
".com", ".cn", ".net", ".org", ".io", ".dev", ".xyz", ".ai"})
}
// hasCodeObject reports a code snippet (backticks, brace pairs, declarations).
func hasCodeObject(text string) bool {
return containsAny(text, []string{"`", "func ", "def ", "return ", ":= ",
"class ", "import ", "package ", "const ", "var ", "=>", "};"})
}
// hasTaskNoun reports a concrete work object noun.
func hasTaskNoun(text string) bool {
return containsAny(text, taskNouns())
}
// taskNouns is the object-noun vocabulary for object detection.
func taskNouns() []string {
return []string{
"报告", "文档", "代码", "脚本", "测试", "文件", "项目", "结果", "数据",
"接口", "配置", "日志", "表格", "图片", "网页", "页面", "服务器", "服务",
"设备", "手机", "电脑", "城市", "天气", "新闻", "价格", "股票", "汇率",
"系统", "数据库", "模型", "论文", "资料", "链接",
// ASCII
"report", "doc", "code", "script", "test", "file", "project", "server",
"device", "config", "log", "url", "api", "database", "page",
}
}
// isClearCompanion reports a pure companionship turn: a weak (emotional/greeting)
// signal with no task verb and no task object (设计 §2.2 L1 弱信号→静息).
func isClearCompanion(text string) bool {
if !hasWeakSignal(text) {
return false
}
if hasStrongSignal(text) {
return false
}
return !hasTaskObject(text)
}
// hasWeakSignal reports a companion-leaning cue (问候/情绪词/日常寒暄).
func hasWeakSignal(text string) bool {
return containsAny(text, weakSignals())
}
// weakSignals is the L1 companion vocabulary.
func weakSignals() []string {
return []string{
"你好", "嗨", "哈喽", "早上好", "早安", "中午好", "下午好", "晚上好",
"晚安", "在吗", "在不在", "抱抱", "么么", "亲亲", "摸摸", "想你",
"爱你", "喜欢你", "想念", "我好", "我累", "累了", "疲惫", "困了",
"无聊", "寂寞", "失眠", "睡不着", "难过", "伤心", "开心", "高兴",
"哈哈", "嘿嘿", "呜呜", "555", "哭", "笑", "心疼", "安慰", "谢谢",
"辛苦", "再见", "明天见", "待会见", "怎么了", "在干嘛", "干嘛呢",
"好呀", "好的", "好哒", "嗯嗯", "嗯", "好", "喔", "哦", "呀",
// ASCII
"hi", "hello", "hey", "good morning", "good night", "good evening",
"hug", "miss you", "love you", "haha", "lol", "aww", "ty", "thanks",
"bye", "thinking", "sleepy", "tired", "happy", "mood", "nice",
}
}
// hasFollowup reports a continuation cue pointing at the previous task
// (设计 §2.2 L2: 上一轮任务的跟进 继续/然后呢/还有吗).
func hasFollowup(text string) bool {
return containsAny(text, followups())
}
func followups() []string {
return []string{
"继续", "然后呢", "还有吗", "还有呢", "接着", "下一步", "接下来",
"之后呢", "那然后", "继续做", "继续写", "继续说", "再来", "再继续",
// ASCII
"continue", "and then", "next", "what else", "also", "go on", "more",
}
}
// isComplex reports whether a task is complex enough for the full state
// (设计 §1 全能态·复杂任务): refactor/migrate/deploy/whole-project/multi-file…
func isComplex(text string) bool {
return containsAny(text, complexMarkers())
}
func complexMarkers() []string {
return []string{
"重构", "搭建", "部署", "迁移", "架构", "实现一个", "写一个", "做一个",
"整个项目", "全部", "所有", "批量", "多文件", "多个文件", "全量",
"综合", "一体化", "测试全部", "修复所有", "上下链路", "系统设计",
// ASCII
"refactor", "migrate", "architect", "build a", "full", "all the",
"multiple files", "entire", "whole", "batch",
}
}
// inferTool names the tool that a task turn would light up, used for the
// capacity_awaken event's Tool field (点亮工具). Order: file path/code → file,
// query/search → web-search, else terminal.
func inferTool(text string) string {
switch {
case hasFileObject(text), hasCodeObject(text):
return "file"
case containsAny(text, []string{
"查", "查询", "搜", "搜索", "查找", "找一下", "search", "query",
"look up", "find", "查一下", "搜索一下",
}):
return "web-search"
case containsAny(text, []string{
"跑", "运行", "执行", "部署", "编译", "构建", "安装", "启动", "重启",
"测试", "run", "deploy", "build", "compile", "install", "start",
"restart", "exec", "test",
}):
return "terminal"
default:
return ""
}
}
// containsAny reports whether text contains any of words (a simple substring
// match; every word is a UTF-8 substring).
func containsAny(text string, words []string) bool {
for _, w := range words {
if strings.Contains(text, w) {
return true
}
}
return false
}
+32
View File
@@ -0,0 +1,32 @@
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
)
// capStateKey is the context key for the per-Run capacity-state accumulator.
type capStateKey struct{}
// capStateAccum holds the mutable capacity state for one Run so the state
// progresses across a multi-step tool loop (quiet→awake→full) without leaking
// between concurrent runs. It lives on the context seeded at the top of Run.
type capStateAccum struct {
state capacity.State
}
// withCapState returns a context carrying acc.
func withCapState(ctx context.Context, acc *capStateAccum) context.Context {
return context.WithValue(ctx, capStateKey{}, acc)
}
// capState returns the accumulator on ctx, or a fresh quiet one when none is
// seeded (a defensive fallback for a caller that invokes toolSchemas outside
// Run, where the state is not meant to persist).
func capState(ctx context.Context) *capStateAccum {
if acc, ok := ctx.Value(capStateKey{}).(*capStateAccum); ok {
return acc
}
return &capStateAccum{state: capacity.StateQuiet}
}
@@ -0,0 +1,97 @@
package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// seedL4Habit writes a canonical intent record memory (the closed-loop write-back
// format produced by capacity.RecordIntent) recording that "搞一下这个部署" is a task
// for persona-a.
func seedL4Habit(t *testing.T, store *memory.Store) {
t.Helper()
ctx := context.Background()
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona-a",
Scope: memory.ScopeUser,
Kind: memory.KindRelationship,
Content: "intent:isTask=true;taskKind=ops;confidence=0.85;text=搞一下这个部署",
}); err != nil {
t.Fatalf("seed L4 habit memory: %v", err)
}
}
// TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv proves the L4 feed: with a
// retrieved user habit recording that the phrase is a task, an otherwise-undecided
// turn wakes in the owner-full env, and the model is advertised the awake tool set.
func TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedL4Habit(t, store)
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
Retriever: memory.NewRetriever(store),
PersonaID: "persona-a",
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "搞一下那个部署")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
// L4 read the learned habit → awake → web-search is advertised (awake set).
if !hasAll(set, "terminal", "file", "web-search") {
t.Fatalf("owner L4 tools = %v, want awake set incl web-search", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead proves the L4 isolation red
// line: in a group/stranger context the loop passes no recall, so the decider falls
// back to L1/L2 and the owner's habit memory is never used to wake the intent.
func TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedL4Habit(t, store)
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
Retriever: memory.NewRetriever(store),
PersonaID: "persona-a",
})
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "搞一下那个部署")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
// Restricted env → L4 silent → undecided turn stays quiet → only quiet tools, no
// web-search, no owner habit read.
if !hasAll(set, "terminal", "file") {
t.Fatalf("restricted tools = %v, want quiet set terminal+file", toolNames(ad.streamReqs[0].Tools))
}
if set["web-search"] {
t.Fatalf("restricted tools = %v, must NOT wake (owner habit not read)", toolNames(ad.streamReqs[0].Tools))
}
}
+158
View File
@@ -0,0 +1,158 @@
package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// capacityToolSet builds a dispatcher over the four representative tools used to
// verify the allowed-set filter: two quiet tools, one awake-only, and one that is
// never allowed in quiet/awake.
func capacityToolSet(t *testing.T) *tool.Dispatcher {
t.Helper()
return dispatcherWith(t,
&mockTool{name: "terminal"},
&mockTool{name: "file"},
&mockTool{name: "web-search"},
&mockTool{name: "json"},
)
}
// toolNames returns the set of tool names advertised in specs. The registry
// List iterates a map, so registration order is not preserved; compare with a
// set (via toolSet) rather than a slice for order-independent assertions.
func toolNames(specs []model.ToolSpec) []string {
out := make([]string, 0, len(specs))
for _, s := range specs {
out = append(out, s.Name)
}
return out
}
func toolSet(specs []model.ToolSpec) map[string]bool {
set := make(map[string]bool, len(specs))
for _, s := range specs {
set[s.Name] = true
}
return set
}
func hasAll(set map[string]bool, names ...string) bool {
for _, n := range names {
if !set[n] {
return false
}
}
return true
}
// TestLoop_Capacity_QuietHidesHeavierTools verifies that with an enabled capacity
// decider a chitchat turn advertises only the quiet allowed set to the model
// (terminal + file, not web-search/json) on the streaming path.
func TestLoop_Capacity_QuietHidesHeavierTools(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "陪你"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file") {
t.Fatalf("quiet tools = %v, want to include terminal+file", toolNames(ad.streamReqs[0].Tools))
}
if set["web-search"] || set["json"] {
t.Fatalf("quiet tools = %v, want to exclude web-search/json", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_WakeExposesWebSearch verifies a task turn exposes the awake
// allowed set (adds web-search) to the model.
func TestLoop_Capacity_WakeExposesWebSearch(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "好的,这就查"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "帮我查一下最近的天气")}); err != nil {
t.Fatalf("Run: %v", err)
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file", "web-search") {
t.Fatalf("awake tools = %v, want to include terminal+file+web-search", toolNames(ad.streamReqs[0].Tools))
}
if set["json"] {
t.Fatalf("awake tools = %v, want to exclude json", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_NilKeepsAllTools verifies the pre-existing behavior: without
// a capacity decider every registered tool is advertised (no filtering).
func TestLoop_Capacity_NilKeepsAllTools(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "你好"}}}}
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好")}); err != nil {
t.Fatalf("Run: %v", err)
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file", "web-search", "json") {
t.Fatalf("nil capacity tools = %v, want all four", toolNames(ad.streamReqs[0].Tools))
}
if len(set) != 4 {
t.Fatalf("nil capacity tools = %v, want exactly four", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_EmitsAwakenEvent verifies a state change surfaces a
// capacity_awaken event through the OnCapacity hook on the streaming path.
func TestLoop_Capacity_EmitsAwakenEvent(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "查到了"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
var got []capacity.Event
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
OnCapacity: func(ev capacity.Event) { got = append(got, ev) },
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "帮我查一下最近的天气")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(got) != 1 {
t.Fatalf("capacity events = %d, want 1 (got %+v)", len(got), got)
}
ev := got[0]
if ev.Type != capacity.EventAwaken || ev.FromState != capacity.StateQuiet || ev.ToState != capacity.StateAwake {
t.Fatalf("event = %+v, want quiet→awake awaken", ev)
}
if ev.Tool != "web-search" {
t.Fatalf("lit tool = %q, want web-search", ev.Tool)
}
}
+5
View File
@@ -19,6 +19,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -81,6 +82,8 @@ type Result struct {
type Loop struct {
agent *agent.Agent
tools *tool.Dispatcher
capacity *capacity.Decider
onCapacity func(capacity.Event)
memory *memory.Store
retriever *memory.Retriever
working *memory.WorkingMemory
@@ -126,6 +129,8 @@ func New(opts Options) *Loop {
return &Loop{
agent: opts.Agent,
tools: opts.Tools,
capacity: opts.Capacity,
onCapacity: opts.OnCapacity,
memory: opts.Memory,
retriever: opts.Retriever,
working: opts.WorkingMemory,
+12
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -26,6 +27,17 @@ type Options struct {
// Tools is the tool dispatcher used to execute model-requested calls. When
// nil the loop falls back to the agent's own dispatcher (if any).
Tools *tool.Dispatcher
// Capacity, when non-nil and enabled, filters the tool schemas advertised to
// the model each round to the allowed set for the current capacity state
// (design/14 唤醒式能力渐进 §4): the loop only exposes the allowed set to the
// model — a view, not a hard boundary (Invoke is left unblocked so an
// emergency call still works). Nil (the default) keeps the pre-existing
// behavior: every registered tool is advertised.
Capacity *capacity.Decider
// OnCapacity, when non-nil, receives capacity_awaken transition events the
// loop observes while deciding a round (design/14 §4), so a caller can
// surface the "能力点亮" progression to the frontend. Nil = no-op.
OnCapacity func(capacity.Event)
// Memory is the long-term memory store used to persist extracted items.
Memory *memory.Store
// Retriever injects relevant memories into the context, re-evaluated every
+102 -5
View File
@@ -1,7 +1,10 @@
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
@@ -9,14 +12,14 @@ import (
// buildRequest assembles a model.Request from the agent's public surface,
// mirroring agent.buildRequest so the streaming path behaves identically to
// RunStep (leading instruction message, tool schemas, model routing).
func (l *Loop) buildRequest(messages []model.Message) model.Request {
func (l *Loop) buildRequest(ctx context.Context, messages []model.Message) model.Request {
req := model.Request{
Model: l.agent.Model(),
Messages: l.assembleMessages(messages),
Temperature: l.temperature(),
MaxTokens: l.agent.MaxTokens(),
Purpose: l.agent.Purpose(),
Tools: l.toolSchemas(),
Tools: l.toolSchemas(ctx, messages),
}
if l.agent.Router() != nil {
if m, _ := l.agent.Router().RouteRequest(req); m != "" {
@@ -50,13 +53,107 @@ func (l *Loop) temperature() *float64 {
}
// toolSchemas advertises the registered tool schemas to the model, or nil when no
// dispatcher is configured.
func (l *Loop) toolSchemas() []model.ToolSpec {
// dispatcher is configured. When a capacity decider is wired and enabled, it
// classifies the current round and returns only the allowed set for that state
// (a view over the dispatcher, per design/14 §4): the model sees/invokes only
// the allowed tools, while Invoke itself stays unblocked. A nil or disabled
// decider keeps the pre-existing behavior (every tool advertised).
func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []model.ToolSpec {
d := l.dispatcher()
if d == nil {
return nil
}
return d.ToolSchemas()
schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() {
return schemas
}
names := make([]string, 0, len(schemas))
for _, s := range schemas {
names = append(names, s.Name)
}
acc := capState(ctx)
dec := l.capacity.Decide(ctx, capacity.Input{
Messages: capacityTexts(messages),
CurrentState: acc.state,
AllToolNames: names,
Recall: l.capacityRecall(ctx, messages),
})
acc.state = dec.State
if l.onCapacity != nil {
for _, ev := range dec.Events {
l.onCapacity(ev)
}
}
allowed := make(map[string]bool, len(dec.AllowedTools))
for _, n := range dec.AllowedTools {
allowed[n] = true
}
out := make([]model.ToolSpec, 0, len(dec.AllowedTools))
for _, s := range schemas {
if allowed[s.Name] {
out = append(out, s)
}
}
return out
}
// capacityTexts extracts the recent conversation text for the capacity decider:
// the last few non-system/non-tool turns, in chronological order (oldest first),
// so the decider's current-turn window sees the latest user message last.
func capacityTexts(messages []model.Message) []string {
texts := make([]string, 0, 6)
for i := len(messages) - 1; i >= 0 && len(texts) < 6; i-- {
m := messages[i]
if m.Role == model.RoleSystem || m.Role == model.RoleTool {
continue
}
if t := messageText(m); t != "" {
texts = append(texts, t)
}
}
for i, j := 0, len(texts)-1; i < j; i, j = i+1, j-1 {
texts[i], texts[j] = texts[j], texts[i]
}
return texts
}
// capacityRecallTopK bounds how many recalled habit memories the capacity L4 layer
// considers per round.
const capacityRecallTopK = 5
// capacityRecall retrieves the owner's relevant long-term memories for the capacity
// L4 habit-alignment layer (design §2.2 L4), the memory feed that lets the decider
// learn the user's task-expression habits. It returns nil (keeping L4 silent) when
// no retriever/persona is configured — backward compatible — or in a restricted
// (group chat / stranger) context, per the isolation red line: L4 learns the owner's
// habits and must never read owner-sensitive memory into a group or stranger intent
// decision (design §2.2 红线: 不读陌生人敏感).
func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []capacity.RecallItem {
if l.retriever == nil || l.personaID == "" {
return nil
}
env := sessionEnvFrom(ctx)
if env.Restricted() {
return nil
}
results, err := l.retriever.Retrieve(ctx, l.personaID, queryFromMessages(messages), capacityRecallTopK)
if err != nil {
l.log.Warn("capacity recall", "error", err, "env", env.ActorEnv)
return nil
}
items := make([]capacity.RecallItem, 0, len(results))
for _, r := range results {
items = append(items, capacity.RecallItem{
Content: r.Item.Content,
Scope: string(r.Item.Scope),
Kind: string(r.Item.Kind),
Tags: r.Item.Tags,
Score: r.Score,
})
}
return items
}
// decodeToolCalls decodes tool calls from a model.Response using the same two
+6
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
@@ -31,6 +32,11 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
return nil, fmt.Errorf("loop: run: %w", ErrNilAgent)
}
// Seed the per-Run capacity accumulator at quiet so the state can progress
// (quiet→awake→full) across a multi-step tool loop; a nil/disabled decider
// leaves it unused. It is scoped to this Run, so concurrent runs never share.
ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet})
msgs := l.resolveImages(ctx, messages)
toolState := &toolLoopState{prevFP: make(map[string]bool)}
res := &Result{}
+1 -1
View File
@@ -19,7 +19,7 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
if adapter == nil {
return nil, fmt.Errorf("loop: stream step: %w", ErrNoAdapter)
}
req := l.buildRequest(messages)
req := l.buildRequest(ctx, messages)
if l.guards != nil {
verdict, err := l.guards.CheckInput(ctx, &req)
+54
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"sync"
"time"
)
// Service composes the Scheduler (cron), the Engine (event rules) and the
@@ -170,6 +171,59 @@ func (s *Service) CancelReminder(ctx context.Context, id string) error {
return s.remind.Cancel(ctx, id)
}
// AddRuleByName registers a named cron/event automation whose action records its
// fire on the service logger (a real, observable side effect) and returns the
// generated rule id. kind must be "cron" or "event"; expression is a cron spec
// for cron rules or a bus subject pattern for event rules. A malformed rule or
// expression returns an error wrapping ErrInvalidRule / ErrInvalidCron so the
// admin handler can answer honestly instead of fabricating a success.
func (s *Service) AddRuleByName(ctx context.Context, name, kind, expression string) (string, error) {
act, err := NewFuncAction(name, func(_ context.Context, payload any) (any, error) {
s.log.Info("admin automation fired", "name", name, "payload", payload)
return map[string]any{"fired": name, "payload": payload}, nil
})
if err != nil {
return "", err
}
rule := Rule{
ID: fmt.Sprintf("auto-%d", time.Now().UnixNano()),
Name: name,
Trigger: TriggerKind(kind),
Expression: expression,
Action: act,
}
if err := s.AddRule(ctx, rule); err != nil {
return "", err
}
return rule.ID, nil
}
// RunRule executes one registered rule's action once and returns its output. It
// is the manual "run now" path for the admin panel. It returns an error wrapping
// ErrNotFound when no rule or reminder carries id.
func (s *Service) RunRule(ctx context.Context, id string) (any, error) {
if id == "" {
return nil, fmt.Errorf("automations: run rule: %w", ErrInvalidRule)
}
for _, r := range s.engine.Rules() {
if r.ID == id {
return r.Action.Execute(ctx, nil)
}
}
s.mu.RLock()
r, ok := s.cronMeta[id]
s.mu.RUnlock()
if ok {
return r.Action.Execute(ctx, nil)
}
for _, rem := range s.remind.List() {
if rem.ID == id {
return map[string]any{"reminder": rem.ID, "message": rem.Message, "at": rem.At}, nil
}
}
return nil, fmt.Errorf("automations: run rule %q: %w", id, ErrNotFound)
}
// Start starts the scheduler, the event engine and the reminder loop.
func (s *Service) Start(ctx context.Context) error {
if err := s.sched.Start(ctx); err != nil {
+9 -2
View File
@@ -160,7 +160,7 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
// the handler leaks nothing sensitive.
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.LLM
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: []AdminLlmProvider{
providers := []AdminLlmProvider{
{
ID: "openai",
Name: "OpenAI",
@@ -179,7 +179,14 @@ func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
Enabled: cfg.Provider == "anthropic",
Fallback: cfg.AnthropicFallback,
},
}})
}
// Fold in providers added at runtime via the admin "add provider" write.
// They are process-lifetime and not bound to the model router, so the panel
// reflects them but the response marks nothing else.
s.llmMu.RLock()
providers = append(providers, s.llmExtra...)
s.llmMu.RUnlock()
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
}
// adminListAutomations lists automations and reminders (admin read).
@@ -0,0 +1,175 @@
package gateway
import (
"encoding/json"
"errors"
"net/http"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
)
// IoT device-control and automation-rule admin writes (owner-only, audited).
// Device control performs a real control on the owning provider; the others
// answer honestly when no scenario/rule store is wired.
// iotControlRequest is the POST /api/v1/admin/iot/{id}/control body.
type iotControlRequest struct {
Action string `json:"action"`
Params map[string]any `json:"params"`
}
// adminIoTControl sends a control instruction to a device through the IoT
// manager, which applies it on the owning provider and publishes a device_update
// event. A real action only when the deployment has a device provider.
func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req iotControlRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Action) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "action is required"))
return
}
mgr, ok := s.iot.(*iot.Manager)
if !ok {
s.auditClaims(r, "iot.control", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "iot_unavailable", "IoT 服务未配置"))
return
}
dev, err := mgr.Control(r.Context(), id, req.Action, req.Params)
if err != nil {
s.auditClaims(r, "iot.control", "error")
writeError(w, mapIotError(err))
return
}
if dev == nil {
s.auditClaims(r, "iot.control", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "device %q not found", id))
return
}
s.auditClaims(r, "iot.control", "ok")
writeJSON(w, http.StatusOK, iotDeviceSummary{ID: dev.ID, Name: dev.Name, Type: dev.Type, State: dev.State, Online: dev.Online})
}
// adminIoTScenario answers honestly: the IoT subsystem has no scenario store yet
// (it owns device list/control/status only), so scenario write lands in M10.
func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "iot.scenario", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 场景存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented})
}
// adminIoTRule answers honestly: no IoT automation-rule store exists yet.
func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "iot.rule", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 自动化规则存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented})
}
// mapIotError maps iot sentinel errors to HTTP errors.
func mapIotError(err error) *apperr.AppError {
switch {
case errors.Is(err, iot.ErrDeviceNotFound):
return apperr.Wrap(apperr.ErrNotFound, "not_found", "device not found")
case errors.Is(err, iot.ErrUnsupported):
return apperr.Wrap(apperr.ErrBadRequest, "unsupported_action", "action not supported")
case errors.Is(err, iot.ErrOffline):
return apperr.Wrap(apperr.ErrConflict, "device_offline", "device offline")
case errors.Is(err, iot.ErrInvalidParam):
return apperr.Wrap(apperr.ErrBadRequest, "invalid_param", "invalid parameter")
default:
return apperr.Wrap(apperr.ErrInternal, "iot_control_failed", "device control failed")
}
}
// automationCreateRequest is the POST /api/v1/admin/automations body.
type automationCreateRequest struct {
Name string `json:"name"`
Kind string `json:"kind"` // cron | event
Expression string `json:"expression"`
}
// adminCreateAutomation registers a real cron/event rule whose action records its
// fire. Invalid cron expressions or malformed rules are answered honestly.
func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) {
var req automationCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Kind) == "" || strings.TrimSpace(req.Expression) == "" {
s.auditClaims(r, "automation.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name, kind and expression are required"))
return
}
auto, ok := s.automations.(*automations.Service)
if !ok {
s.auditClaims(r, "automation.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
return
}
id, err := auto.AddRuleByName(r.Context(), req.Name, req.Kind, req.Expression)
if err != nil {
s.auditClaims(r, "automation.create", "error")
if errors.Is(err, automations.ErrInvalidCron) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_cron", "invalid cron expression"))
return
}
if errors.Is(err, automations.ErrInvalidRule) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_rule", "invalid rule"))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to add automation"))
return
}
s.auditClaims(r, "automation.create", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "name": req.Name, "kind": req.Kind})
}
// adminRunAutomation manually triggers a registered rule's action once.
func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
auto, ok := s.automations.(*automations.Service)
if !ok {
s.auditClaims(r, "automation.run", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
return
}
out, err := auto.RunRule(r.Context(), id)
if err != nil {
s.auditClaims(r, "automation.run", "error")
if errors.Is(err, automations.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to run automation"))
return
}
s.auditClaims(r, "automation.run", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "output": out})
}
// adminDeleteAutomation removes a registered rule (cron or event) by id.
func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
auto, ok := s.automations.(*automations.Service)
if !ok {
s.auditClaims(r, "automation.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
return
}
if err := auto.RemoveRule(r.Context(), id); err != nil {
s.auditClaims(r, "automation.delete", "error")
if errors.Is(err, automations.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to delete automation"))
return
}
s.auditClaims(r, "automation.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
}
@@ -0,0 +1,278 @@
package gateway
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// LLM-provider and channel-lifecycle admin writes (owner-only, audited). The LLM
// test performs a real model call; provider "add" records a process-lifetime
// entry. Channel open/close drive the real adapter lifecycle; the identity test
// is the structural actor × whitelist rule, never prompt text.
const (
// ownerProfilePath is the owner whitelist loaded for the structural identity
// test. It mirrors the path wire.go uses for owner recognition.
ownerProfilePath = "configs/users/owner.yaml"
// llmTestTimeout bounds a single LLM probe request.
llmTestTimeout = 30 * time.Second
// llmProbePrompt is the minimal prompt used to verify a model responds.
llmProbePrompt = "Reply with the single word: OK"
)
// llmProviderRequest is the POST /api/v1/admin/llm body for adding a provider.
type llmProviderRequest struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Multimodal bool `json:"multimodal"`
Fallback string `json:"fallback"`
}
// adminAddLlmProvider records a provider for the process lifetime, reflected in
// the GET /admin/llm listing. It is not bound to the model router (config is
// immutable after startup), so it is reported as temporary in the response.
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
var req llmProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Model) == "" {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and model are required"))
return
}
id := req.ID
if id == "" {
id = newIDHex()
}
provider := AdminLlmProvider{ID: id, Name: req.Name, BaseURL: req.BaseURL, Model: req.Model, Multimodal: req.Multimodal, Fallback: req.Fallback}
s.llmMu.Lock()
s.llmExtra = append(s.llmExtra, provider)
s.llmMu.Unlock()
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"provider": provider, "temporary": true})
}
// llmTestRequest is the POST /api/v1/admin/llm/test body.
type llmTestRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Prompt string `json:"prompt"`
}
// adminTestLlm performs a real completion against the supplied provider/model
// endpoint and reports the result — a genuine round-trip, not a placeholder.
func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
var req llmTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
s.auditClaims(r, "llm.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "model and baseUrl are required"))
return
}
provider := req.Provider
if provider == "" {
provider = model.ProviderOpenAI
}
adapter, err := model.NewChatAdapter(config.LLMConfig{Provider: provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model})
if err != nil {
s.auditClaims(r, "llm.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
return
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = llmProbePrompt
}
ctx, cancel := context.WithTimeout(r.Context(), llmTestTimeout)
defer cancel()
resp, err := adapter.Chat(ctx, model.Request{
Messages: []model.Message{model.TextMessage(model.RoleUser, prompt)},
}, model.Options{Timeout: llmTestTimeout})
if err != nil {
s.auditClaims(r, "llm.test", "error")
s.log.Error("llm test", "provider", provider, "model", req.Model, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_test_failed", "model call failed: %s", err.Error()))
return
}
s.auditClaims(r, "llm.test", "ok")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens})
}
// channelActionRequest is the POST /api/v1/admin/channels/open|close body.
type channelActionRequest struct {
Name string `json:"name"`
}
// adminChannelOpen connects a registered channel adapter (real lifecycle call).
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.open")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.open", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Connect(r.Context()); err != nil {
s.auditClaims(r, "channel.open", "error")
s.log.Error("channel open", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
return
}
s.auditClaims(r, "channel.open", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
}
// adminChannelClose disconnects a registered channel adapter (real lifecycle call).
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.close")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.close", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Disconnect(r.Context()); err != nil {
s.auditClaims(r, "channel.close", "error")
s.log.Error("channel close", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error()))
return
}
s.auditClaims(r, "channel.close", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
}
// channelTestRequest is the POST /api/v1/admin/channels/test body: a channel
// account id plus the conversation shape, resolved by the structural actor rule.
type channelTestRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
IsGroup bool `json:"isGroup"`
}
// adminChannelTest runs the structural identity rule (actor metadata × owner
// whitelist), never prompt text. It loads the owner whitelist from disk so a
// mismatch degrades to the conservative default (member/stranger).
func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
var req channelTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.AccountID) == "" {
s.auditClaims(r, "channel.identity-test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "accountId is required"))
return
}
msg := adapter.UnifiedMessage{
Platform: req.Platform,
ChannelID: req.AccountID,
SenderID: req.AccountID,
Sender: adapter.Actor{UserID: req.AccountID, IsGroup: req.IsGroup},
}
if req.IsGroup {
msg.ChannelType = adapter.ChannelGroup
} else {
msg.ChannelType = adapter.ChannelDirect
}
owner := loadOwnerProfile()
actor := session.ResolveActor(msg, owner)
env := session.EnvFor(actor)
s.auditClaims(r, "channel.identity-test", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"role": string(actor.Role),
"owner": actor.IsOwner(),
"isGroup": actor.IsGroupChat(),
"env": string(env),
"verdict": describeIdentity(actor, owner),
})
}
// channelName decodes the {name} body for open/close routes, writing a 400 on
// failure. It reports uniqueness of the decode via ok.
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) {
var req channelActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return "", false
}
if strings.TrimSpace(req.Name) == "" {
s.auditClaims(r, op, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return "", false
}
return req.Name, true
}
// findChannel returns the registered channel adapter named name, if any.
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
if caps == nil {
return nil, false
}
for _, cap := range caps.List(capreg.KindChannel) {
if ch, ok := cap.Value.(sdk.Channel); ok && ch.Name() == name {
return ch, true
}
}
return nil, false
}
// loadOwnerProfile loads the owner whitelist from disk, returning nil on any
// failure so the identity test degrades to the conservative deny.
func loadOwnerProfile() *user.OwnerProfile {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
return nil
}
return p
}
// describeIdentity renders the structural verdict for the identity test.
func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
switch {
case a.IsGroupChat():
return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
case a.IsOwner():
return "匹配主人白名单 → owner(全量访问,记忆可读写)"
default:
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
}
}
// newIDHex returns a 16-byte hex id.
func newIDHex() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("id-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
@@ -0,0 +1,275 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// persona-to-identity and API-key admin writes (owner-only, audited). Persona
// writes register into the persona registry (real runtime effect) and persist a
// YAML file so they survive a restart; API keys are minted/revoked in the
// auth.APIKey store.
const personaDir = "configs/personas"
// personaUpsertRequest is the POST/PUT /api/v1/admin/personas body.
type personaUpsertRequest struct {
ID string `json:"id"`
Name string `json:"name"`
CharacterRef string `json:"characterRef"`
Role string `json:"role"`
SystemPrompt string `json:"systemPrompt"`
Personality string `json:"personality"`
SpeechStyle string `json:"speechStyle"`
Catchphrase string `json:"catchphrase"`
Background string `json:"background"`
Values []string `json:"values"`
Modes []string `json:"modes"`
Mode string `json:"mode"`
MemoryNS string `json:"memoryNamespace"`
}
// adminCreatePersona registers a new persona in the registry (real runtime
// effect) and persists it to the persona YAML library so it survives a restart.
func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
var req personaUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.ID) == "" || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required"))
return
}
reg, ok := s.personas.(*personareg.Registry)
if !ok {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
return
}
if _, exists := reg.Persona(req.ID); exists {
s.auditClaims(r, "persona.create", "error")
writeError(w, &apperr.AppError{Code: "duplicate", Message: "人格已存在,请使用编辑", HTTPCode: http.StatusConflict})
return
}
p := personaFromRequest(req)
if err := savePersonaYAML(p); err != nil {
s.auditClaims(r, "persona.create", "error")
s.log.Error("persona persist", "id", req.ID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
return
}
if err := reg.RegisterPersona(p); err != nil {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register persona"))
return
}
if err := reg.RegisterCharacter(characterFromRequest(req)); err != nil {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register character"))
return
}
s.auditClaims(r, "persona.create", "ok")
writeJSON(w, http.StatusCreated, personaSummaryOf(p, characterFromRequest(req)))
}
// adminUpdatePersona edits an existing persona, persisting the YAML and
// replacing the in-memory persona/character.
func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req personaUpsertRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required"))
return
}
req.ID = id
reg, ok := s.personas.(*personareg.Registry)
if !ok {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
return
}
if _, exists := reg.Persona(id); !exists {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "persona %q not found", id))
return
}
p := personaFromRequest(req)
if err := savePersonaYAML(p); err != nil {
s.auditClaims(r, "persona.update", "error")
s.log.Error("persona persist", "id", id, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
return
}
if err := reg.UpdatePersona(p); err != nil {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update persona"))
return
}
if err := reg.UpdateCharacter(characterFromRequest(req)); err != nil {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update character"))
return
}
s.auditClaims(r, "persona.update", "ok")
writeJSON(w, http.StatusOK, personaSummaryOf(p, characterFromRequest(req)))
}
// adminCreateUser answers honestly: there is no persistent user table yet (the
// deployment is a single admin account + API keys), so user creation is deferred
// to M10 rather than fabricated.
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "user.create", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "新增用户/角色存储为 M10 提供(当前为单 admin + APIKey 现状)", HTTPCode: http.StatusNotImplemented})
}
// apiKeyIssueRequest is the POST /api/v1/admin/users/apikey body.
type apiKeyIssueRequest struct {
Role string `json:"role"`
}
// adminIssueApiKey mints a real API key bound to a role and returns the plaintext
// once (the only time it is visible). The id in the response is the masked form
// the admin list shows, so it can be used to revoke the key later.
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
var req apiKeyIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
role, err := auth.ParseRole(req.Role)
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role"))
return
}
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
key, err := keys.Generate(role)
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key"))
return
}
s.auditClaims(r, "apikey.issue", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)})
}
// adminRevokeApiKey revokes a key by its masked id (the identifier the admin list
// returns). It reports not-found when no key carries that mask.
func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "apikey.revoke", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
if !keys.RevokeByMask(id) {
s.auditClaims(r, "apikey.revoke", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "api key not found"))
return
}
s.auditClaims(r, "apikey.revoke", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
}
// personaFromRequest builds a loader.Persona from the upsert request, filling a
// derived system prompt/characterRef when omitted so a minimal create validates.
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
systemPrompt := strings.TrimSpace(req.SystemPrompt)
if systemPrompt == "" {
systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role)
}
characterRef := strings.TrimSpace(req.CharacterRef)
if characterRef == "" {
characterRef = req.ID
}
return &loader.Persona{
ID: req.ID,
Name: req.Name,
CharacterRef: characterRef,
Identity: loader.Identity{Role: req.Role, SystemPrompt: systemPrompt},
Personality: req.Personality,
SpeechStyle: req.SpeechStyle,
Catchphrase: req.Catchphrase,
Background: req.Background,
Values: req.Values,
Emotion: loader.EmotionParams{MemoryNamespace: strings.TrimSpace(req.MemoryNS)},
}
}
// characterFromRequest builds the registry.Character bound to the persona.
func characterFromRequest(req personaUpsertRequest) *personareg.Character {
modes := append([]string(nil), req.Modes...)
if len(modes) == 0 && req.Mode != "" {
modes = []string{req.Mode}
}
return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes}
}
// personaSummaryOf maps a persona+character to its wire summary.
func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
return PersonaSummary{
ID: p.ID,
Name: c.Name,
Character: c.Name,
IsDefault: c.Default,
Mode: firstMode(c.Modes),
MemoryNS: p.MemoryNamespace(),
}
}
// savePersonaYAML persists p to the persona library as <id>.yaml so it survives
// a restart (wire.go loads this directory). The id is sanitized so user input
// cannot traverse out of the library directory.
func savePersonaYAML(p *loader.Persona) error {
data, err := yaml.Marshal(p)
if err != nil {
return fmt.Errorf("persona: marshal %q: %w", p.ID, err)
}
if err := os.MkdirAll(personaDir, 0o755); err != nil {
return fmt.Errorf("persona: mkdir: %w", err)
}
path := filepath.Join(personaDir, sanitizeID(p.ID)+".yaml")
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("persona: write %s: %w", path, err)
}
return nil
}
// sanitizeID keeps only URL/path-safe characters so a persona id cannot escape
// the persona directory.
func sanitizeID(id string) string {
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
@@ -0,0 +1,156 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// Plugin uninstall, memory retrieval and session export admin writes
// (owner-only, audited). Uninstall removes a capability from the registry; memory
// search runs a real retrieval; session export returns the session metadata.
// pluginActionRequest is the POST /api/v1/admin/plugins/install|uninstall body.
type pluginActionRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
}
// adminPluginInstall answers honestly: installing a plugin needs a real plugin
// source (compiled binary or skill YAML), which the admin API cannot supply. It
// returns a clear 501 rather than fabricating a registered plugin.
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "plugin.install", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "插件安装需插件源码清单(编译二进制/YAML)为 M10 提供;卸载可直接调用 registry", HTTPCode: http.StatusNotImplemented})
}
// adminPluginUninstall removes a registered capability from the registry — a real
// effect that stops the plugin being enumerated or dispatched.
func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
var req pluginActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" {
s.auditClaims(r, "plugin.uninstall", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return
}
reg, ok := s.caps.(*capreg.Registry)
if !ok {
s.auditClaims(r, "plugin.uninstall", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
return
}
if err := reg.Remove(req.Name); err != nil {
s.auditClaims(r, "plugin.uninstall", "error")
if errors.Is(err, capreg.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "plugin %q not found", req.Name))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
return
}
s.auditClaims(r, "plugin.uninstall", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "uninstalled": true})
}
// memorySearchRequest is the POST /api/v1/admin/memories/search body.
type memorySearchRequest struct {
Query string `json:"query"`
PersonaID string `json:"personaId"`
TopK int `json:"topK"`
}
// memorySearchHit is one admin memory retrieval hit.
type memorySearchHit struct {
ID string `json:"id"`
Scope string `json:"scope"`
Text string `json:"text"`
Score float64 `json:"score"`
}
// adminMemorySearch runs a real retrieval over the memory store. The retrieval
// ranks by keyword relevance × decay; the semantic/vector path requires the
// composition root to inject the vector index, so this answers on the keyword
// path and reports semantic=false honestly.
func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) {
var req memorySearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Query) == "" {
s.auditClaims(r, "memory.search", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "query is required"))
return
}
store, ok := s.memories.(*memory.Store)
if !ok {
s.auditClaims(r, "memory.search", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_unavailable", "memory store is not configured"))
return
}
personaID := strings.TrimSpace(req.PersonaID)
if personaID == "" {
personaID = "cyrene"
}
topK := req.TopK
if topK <= 0 {
topK = 10
}
results, err := memory.NewRetriever(store).Retrieve(r.Context(), personaID, req.Query, topK)
if err != nil {
s.auditClaims(r, "memory.search", "error")
s.log.Error("memory search", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_search_failed", "failed to search memory"))
return
}
hits := make([]memorySearchHit, 0, len(results))
for _, res := range results {
hits = append(hits, memorySearchHit{ID: res.Item.ID, Scope: string(res.Item.Scope), Text: res.Item.Content, Score: res.Score})
}
s.auditClaims(r, "memory.search", "ok")
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "personaId": personaID, "semantic": false, "hits": hits})
}
// adminExportSession exports a session as JSON. Without a conversation-message
// store the export carries the session identity metadata that exists today; a
// full transcript export is deferred to M10.
func (s *Server) adminExportSession(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
sess := s.findSession(r.Context(), id)
if sess == nil {
s.auditClaims(r, "session.export", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.auditClaims(r, "session.export", "ok")
writeJSON(w, http.StatusOK, map[string]any{"session": summaryOfSession(*sess)})
}
// findSession returns the session whose SessionID equals id, if known.
func (s *Server) findSession(ctx context.Context, id string) *session.Session {
if s.sessions == nil {
return nil
}
list, err := s.sessions.List(ctx)
if err != nil {
return nil
}
for i := range list {
if list[i].SessionID == id {
return &list[i]
}
}
return nil
}
+28 -1
View File
@@ -130,7 +130,34 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
msgs := userChatMessages(req.Text, req.Image)
if s.hub.orchestrator != nil {
sr, res := s.hub.orchestrator.SubmitSync(r.Context(), req.SessionID, msgs, loop.OwnerEnv())
if !sr.Accepted {
if sr.PendingFull {
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
} else {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required"))
}
return
}
// A busy session returns a non-blocking ack (design/15 §2.1) instead of
// blocking the request on a turn that may run for a while.
if sr.Pending {
writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth})
return
}
if sr.Err != nil {
s.log.Error("chat run", "err", sr.Err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
return
}
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
return
}
// Legacy synchronous path when no orchestrator is wired (a hub built without
// one keeps the exact pre-design/15 reply contract).
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, msgs)
if err != nil {
s.log.Error("chat run", "err", err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
+55 -3
View File
@@ -25,6 +25,7 @@ import (
"net"
"net/http"
"strconv"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
@@ -234,9 +235,15 @@ type Server struct {
voice VoiceService
streaming bool
frontend fs.FS
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
// llmMu guards llmExtra, the process-lifetime providers added at runtime via
// the admin "add provider" write. They are reflected in GET /admin/llm but are
// not bound to the model router (config is immutable after startup), so they
// are reported as temporary.
llmMu sync.RWMutex
llmExtra []AdminLlmProvider
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
}
// New validates cfg and deps and returns a ready Server. Auth is required; the
@@ -272,6 +279,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
log: sourcedLog(deps.Log),
}
s.hub = newHub(s)
s.hub.orchestrator = newOrchestrator(s)
s.hub.start()
return s, nil
}
@@ -315,6 +323,32 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/admin/observability/audit", adminAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
// Admin writes: owner-only, audited. Reads above accept owner/admin; writes
// are reserved for the owner (RBAC red line). Each handler persists to the
// real backing store or answers honestly with a clear "M10 / not-wired" error
// rather than fabricating a success.
ownerAuth := s.auth.Authorize(auth.RoleOwner)
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))
mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona)))
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey)))
mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey)))
mux.Handle("POST /api/v1/admin/iot/{id}/control", ownerAuth(http.HandlerFunc(s.adminIoTControl)))
mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario)))
mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule)))
mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation)))
mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation)))
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))
mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm)))
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession)))
// External webhook callbacks (e.g. the webhook channel) are mounted without
// gateway auth; each handler enforces its own signature/authorization.
for path, hdl := range s.webhooks {
@@ -374,6 +408,24 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Close frees the hub's bus subscription and closes live connections.
func (s *Server) Close() { s.hub.close() }
// newOrchestrator builds the session-message orchestrator (design/15) bound to
// this server's hub. The TurnRunner shares the hub's runTurn (so a turn always
// emits the same stream contract), and the StateSink fans per-session state
// transitions out through the hub broadcast for the frontend. It is wrapped in a
// function so the composition root can later override the injection (an explicit
// wiring point rather than a hard-coded construct).
func newOrchestrator(s *Server) *Orchestrator {
return NewOrchestrator(OrchestratorOptions{
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
return s.hub.runTurn(ctx, sessionID, messages, env)
},
Emit: func(sessionID, typ string, data map[string]any) {
s.hub.broadcast(wsEvent{Type: typ, Data: data})
},
Log: s.log,
})
}
// sourcedLog returns a logger tagged with source "gateway", substituting a
// discard logger for nil so Server can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger {
@@ -0,0 +1,443 @@
package gateway
import (
"context"
"io"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file implements the session-message orchestrator (DevDocs/design/15).
//
// It is deliberately NOT a task engine. design/04 §2 (task/subagent) decides
// whether a heavy job gets pushed to the background; this orchestrator decides
// how NEW user messages queue and resume while a session is busy. The two layers
// are orthogonal and compose: a session runs a task (04), a new message arrives
// mid-run (15), and the orchestrator holds it in a bounded pending queue and
// resumes it with the previous turn's context once the current turn finishes.
//
// Non-blocking is a hard constraint: Submit/Interrupt never wait on a running
// turn. The queue is bounded per session and isolated across sessions, and no
// identity decision is made here — the orchestrator only orders intent/messages,
// identity stays in the channel actor metadata (project red line).
// Mode is the per-session busy-state lane of the orchestrator. It is exposed so
// a loop/subagent hook can raise it to executing/subagent (an injection point
// the composition root may wire later), but the orchestrator itself tracks an
// abstract "busy" via mode != ModeIdle.
type Mode string
const (
// ModeIdle means the session has no turn in flight and no resume pending.
ModeIdle Mode = "idle"
// ModeThinking means a turn is in flight (the initial start state).
ModeThinking Mode = "thinking"
// ModeExecuting means a turn is executing tools/substeps.
ModeExecuting Mode = "executing"
// ModeSubagent means a turn is delegated to a subagent (design/04 §2).
ModeSubagent Mode = "subagent"
)
// State event types pushed over the WS channel to the frontend. The data map of
// each carries sessionId so a client can correlate per-session state.
const (
EventSessionBusy = "session_busy"
EventPendingAck = "pending_ack"
EventPendingDrain = "pending_drain"
EventSessionIdle = "session_idle"
EventSessionInterrupt = "session_interrupted"
EventPendingFull = "pending_full"
)
const (
// defaultMaxPending bounds the per-session pending queue (design/15 §4).
defaultMaxPending = 5
// defaultMaxTail bounds the conversation tail kept for continuation, so the
// resumed context is incremental, not a full replay (design/15 §2.2, §4).
defaultMaxTail = 20
// defaultSessionID is used when a caller passes an empty session id.
defaultSessionID = "web"
)
// SubmitResult is the outcome of handing one user turn to the orchestrator. It
// is always returned without blocking: Accepted is false only when the turn was
// rejected (pending queue full, or an empty turn), never because a turn is busy.
type SubmitResult struct {
// Accepted reports whether the turn was taken. A false value with
// PendingFull or Empty carries the reject reason; it never means "try later".
Accepted bool
// Immediate reports that a turn started now because the session was idle.
Immediate bool
// Pending reports that the turn was queued behind a busy turn.
Pending bool
// QueueDepth is the pending depth after this insertion (0 when Immediate).
QueueDepth int
// PendingFull reports the turn was rejected because the queue is at capacity
// (design/15 §4: bounded pending, over-capacity is refused with a hint).
PendingFull bool
// Empty reports the turn was rejected because it carried no text or image.
Empty bool
// Err is set on the synchronous path when the run errored; the REST caller
// uses it to return a 5xx rather than a fabricated reply.
Err error
}
// TurnRunner executes one agent turn and returns its result. The gateway wires
// it to the loop plus the streaming broadcast, so the orchestrator stays agnostic
// of how a turn is physically driven (it never re-implements a loop engine).
type TurnRunner func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error)
// StateSink reports a per-session state transition (busy/pending/idle/interrupt)
// to the transport. The gateway wires it to the WS hub broadcast.
type StateSink func(sessionID, typ string, data map[string]any)
// OrchestratorOptions configures an Orchestrator. Run and Log are required
// (Log may be a discard logger); Emit is optional (state events are dropped when
// nil). MaxPending/MaxTail fall back to the defaults when non-positive.
type OrchestratorOptions struct {
Run TurnRunner
Emit StateSink
Log *slog.Logger
MaxPending int
MaxTail int
}
// sessionState is the per-session message state (design/15 §2). Every mutation
// happens under the Orchestrator mutex; the actual loop run is done outside the
// lock so a slow model call never blocks Submit.
type sessionState struct {
mode Mode
gen uint64 // generation: bumped on interrupt and on a fresh top-level turn
cancel context.CancelFunc
pending []pendingMsg
tail []model.Message // bounded conversation tail for context continuation
}
// pendingMsg is one queued user turn. It carries its own env so a resume runs
// under the same memory-isolation boundary the message arrived with.
type pendingMsg struct {
messages []model.Message
env loop.SessionEnv
at time.Time
}
// Orchestrator owns the per-session message state machine. It is concurrency-safe
// and non-blocking: Submit/Interrupt lock only to mutate state, then hand the
// actual run off to a goroutine. It is the single owner of "is this session busy"
// for the chat path; the loop/task/subagent engines are reused, not re-created.
type Orchestrator struct {
mu sync.Mutex
states map[string]*sessionState
run TurnRunner
emit StateSink
log *slog.Logger
maxPending int
maxTail int
}
// NewOrchestrator builds an orchestrator from opts. Run is required: it is the
// turn-driver the orchestrator reuses (the loop, never a re-implemented engine).
// A nil Run makes every Submit reject rather than run, so a misconfiguration
// cannot silently leave a session busy. Tests inject a fake runner here.
func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
maxPending := opts.MaxPending
if maxPending <= 0 {
maxPending = defaultMaxPending
}
maxTail := opts.MaxTail
if maxTail <= 0 {
maxTail = defaultMaxTail
}
if opts.Log == nil {
opts.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &Orchestrator{
states: make(map[string]*sessionState),
run: opts.Run,
emit: opts.Emit,
log: opts.Log,
maxPending: maxPending,
maxTail: maxTail,
}
}
// Submit hands one user turn to the session. It never blocks: an idle session
// starts a turn on a background goroutine and returns Immediate; a busy session
// enqueues the turn into the bounded pending queue and returns Pending; a full
// queue refuses with PendingFull. Interrupt words are NOT auto-detected here —
// the caller (frontend/intent classifier) calls Interrupt when the user asks to
// stop, per design/15 §3 (interrupt is an intent, not an identity decision).
func (o *Orchestrator) Submit(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) SubmitResult {
sr, _ := o.submitInternal(ctx, o.normalize(sessionID), messages, env, false)
return sr
}
// SubmitSync is the synchronous variant used by the REST non-streaming path. An
// idle session runs the turn to completion and returns the Result; a busy session
// returns a Pending result without blocking so the caller can reply with a
// non-blocking ack (design/15 §2.1). It never waits on an in-flight turn.
func (o *Orchestrator) SubmitSync(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (SubmitResult, *loop.Result) {
return o.submitInternal(ctx, o.normalize(sessionID), messages, env, true)
}
// submitInternal is the shared Submit/SubmitSync body. sync selects the
// synchronous turn path (REST) vs the background goroutine path (WS).
func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv, sync bool) (SubmitResult, *loop.Result) {
if o.run == nil {
o.log.Error("orchestrator submit", "err", "no runner configured", "session", sessionID)
return SubmitResult{Accepted: false}, nil
}
if o.messagesEmpty(messages) {
return SubmitResult{Empty: true}, nil
}
o.mu.Lock()
st := o.states[sessionID]
if st == nil {
st = &sessionState{mode: ModeIdle}
o.states[sessionID] = st
}
if st.mode != ModeIdle {
if len(st.pending) >= o.maxPending {
mode := st.mode
o.mu.Unlock()
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": mode, "max": o.maxPending})
return SubmitResult{PendingFull: true}, nil
}
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, at: time.Now()})
depth := len(st.pending)
mode := st.mode
o.mu.Unlock()
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth}, nil
}
st.gen++
gen := st.gen
st.mode = ModeThinking
st.tail = o.boundedTail(messages)
runCtx, cancel := context.WithCancel(ctx)
st.cancel = cancel
o.mu.Unlock()
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
if sync {
res, err := o.run(runCtx, sessionID, messages, env)
if err != nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err)
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
}
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel)
return SubmitResult{Accepted: true, Immediate: true}, nil
}
// goTurn runs a single turn on a background goroutine and, when it finishes,
// hands the outcome to completeTurn so the orchestrator can resume the queue.
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc) {
defer cancel()
res, err := o.run(ctx, sessionID, messages, env)
if err != nil && ctx.Err() == nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err)
}
// completeTurn runs after a turn settles (either it finished, errored, or was
// canceled). It folds the assistant reply into the session's continuation tail,
// then pops the next pending turn and resumes it carrying that tail — so the next
// message "remembers" the prior context instead of starting from a blank slate
// (design/15 §2.2: incremental, not full replay). A stale generation (an
// interrupt or a newer top-level turn superseded this one) is a no-op.
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error) error {
o.mu.Lock()
st := o.states[sessionID]
if st == nil || st.gen != gen {
o.mu.Unlock()
return nil
}
if res != nil && res.Text != "" {
st.tail = o.boundedTail(concatMessages(st.tail, model.TextMessage(model.RoleAssistant, res.Text)))
}
if len(st.pending) > 0 {
next := st.pending[0]
st.pending = st.pending[1:]
st.tail = o.boundedTail(concatMessages(st.tail, next.messages...))
st.mode = ModeThinking
st.gen++
gen = st.gen
runCtx, cancel := context.WithCancel(context.Background())
st.cancel = cancel
depth := len(st.pending)
resume := append([]model.Message(nil), st.tail...)
env := next.env
o.mu.Unlock()
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth})
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel)
return nil
}
st.mode = ModeIdle
st.cancel = nil
o.mu.Unlock()
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": ModeIdle})
return nil
}
// Interrupt cancels the in-flight turn, clears the pending queue and returns the
// session to idle (design/15 §3, the "停下/算了/换个话题" path). It is the only
// way a busy session resets without draining the queue; the running goroutine's
// completion becomes a stale generation and does not resume anything. It returns
// the number of pending turns that were dropped.
func (o *Orchestrator) Interrupt(sessionID string) int {
return o.interrupt(o.normalize(sessionID))
}
// InterruptAll cancels every in-flight session and clears all pending queues. It
// is used on gateway shutdown so no background turn outlives the hub. It returns
// the total number of dropped pending turns.
func (o *Orchestrator) InterruptAll() int {
o.mu.Lock()
total := 0
for id, st := range o.states {
total += o.interruptLocked(id, st)
}
o.mu.Unlock()
return total
}
func (o *Orchestrator) interrupt(sessionID string) int {
o.mu.Lock()
defer o.mu.Unlock()
return o.interruptLocked(sessionID, o.states[sessionID])
}
func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState) int {
if st == nil || st.mode == ModeIdle {
return 0
}
if st.cancel != nil {
st.cancel()
}
dropped := len(st.pending)
st.pending = nil
st.gen++
st.mode = ModeIdle
st.cancel = nil
o.emitState(sessionID, EventSessionInterrupt, map[string]any{"sessionId": sessionID, "mode": ModeIdle, "dropped": dropped})
return dropped
}
// SetMode raises the reported busy-state lane of a session (thinking →
// executing → subagent). It is an injection point a loop/subagent hook can call;
// any non-idle mode keeps the session busy, and idle un-busies it. A nil session
// is created lazily so a hook may set a mode before the first message.
func (o *Orchestrator) SetMode(sessionID string, mode Mode) {
sessionID = o.normalize(sessionID)
o.mu.Lock()
st := o.states[sessionID]
if st == nil {
st = &sessionState{mode: mode}
o.states[sessionID] = st
} else {
st.mode = mode
}
o.mu.Unlock()
if mode == ModeIdle {
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": mode})
return
}
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": mode})
}
// Busy reports whether the session currently has a turn in flight.
func (o *Orchestrator) Busy(sessionID string) bool {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
return st != nil && st.mode != ModeIdle
}
// QueueDepth reports the number of pending turns for a session.
func (o *Orchestrator) QueueDepth(sessionID string) int {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
if st == nil {
return 0
}
return len(st.pending)
}
// ModeOf reports the current busy-state lane of a session (idle when unknown).
func (o *Orchestrator) ModeOf(sessionID string) Mode {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
if st == nil {
return ModeIdle
}
return st.mode
}
// normalize maps an empty session id to the default so the state map is stable
// for callers that omit it (the Web main session).
func (o *Orchestrator) normalize(sessionID string) string {
if sessionID == "" {
return defaultSessionID
}
return sessionID
}
// emitState forwards a state transition to the transport when one is wired.
func (o *Orchestrator) emitState(sessionID, typ string, data map[string]any) {
if o.emit != nil {
o.emit(sessionID, typ, data)
}
}
// boundedTail returns a fresh copy of the last maxTail messages, so continuation
// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input
// slice or another state's tail.
func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message {
if len(msgs) <= o.maxTail {
return append([]model.Message(nil), msgs...)
}
out := make([]model.Message, o.maxTail)
copy(out, msgs[len(msgs)-o.maxTail:])
return out
}
// messagesEmpty reports whether a turn carries no usable text or image, so the
// orchestrator does not mark a session busy on a vacuous message.
func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool {
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text != "" {
return false
}
if c.Type == model.ContentTypeImage && c.ImageURL != "" {
return false
}
}
}
return true
}
// concatMessages returns a new slice combining a and b (never reusing a or b
// backing arrays) so the continuation tail is safe to hand to a concurrent run.
func concatMessages(a []model.Message, b ...model.Message) []model.Message {
out := make([]model.Message, 0, len(a)+len(b))
out = append(out, a...)
out = append(out, b...)
return out
}
@@ -0,0 +1,480 @@
package gateway
import (
"context"
"fmt"
"log/slog"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// callRecord is one invocation captured by the fake runner.
type callRecord struct {
sessionID string
messages []model.Message
env loop.SessionEnv
ctx context.Context
}
// gatedRunner is a controllable TurnRunner. Each Run blocks until it consumes one
// ticket from tickets, or until ctx is canceled (the interrupt path). When it
// proceeds it records the call and returns a deterministic reply. A nil err
// produces a reply holding the *incoming* messages as text, so a test can assert
// exactly what context the resumed turn carried.
type gatedRunner struct {
mu sync.Mutex
calls []callRecord
tickets chan struct{}
err error
}
func newGatedRunner(cap int) *gatedRunner {
return &gatedRunner{tickets: make(chan struct{}, cap)}
}
func (g *gatedRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
select {
case <-g.tickets:
case <-ctx.Done():
return nil, ctx.Err()
}
g.mu.Lock()
n := len(g.calls) + 1
g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx})
g.mu.Unlock()
if g.err != nil {
return nil, g.err
}
reply := n + len(messages)
return &loop.Result{Text: fmt.Sprintf("reply-%d", reply), Steps: 1, StopReason: "stop"}, nil
}
// waitCalls blocks until at least n calls are recorded and returns a copy of the
// first n, so a test can advance the gated chain deterministically.
func (g *gatedRunner) waitCalls(n int) []callRecord {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
g.mu.Lock()
if len(g.calls) >= n {
out := append([]callRecord(nil), g.calls[:n]...)
g.mu.Unlock()
return out
}
g.mu.Unlock()
time.Sleep(time.Millisecond)
}
panic("timed out waiting for runner calls")
}
// count returns the total recorded calls.
func (g *gatedRunner) count() int {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.calls)
}
// eventRec is one state transition captured by the recording sink.
type eventRec struct {
sessionID, typ string
data map[string]any
}
// recordingSink captures every emitted state transition.
type recordingSink struct {
mu sync.Mutex
events []eventRec
}
func (s *recordingSink) Emit(sessionID, typ string, data map[string]any) {
s.mu.Lock()
s.events = append(s.events, eventRec{sessionID: sessionID, typ: typ, data: data})
s.mu.Unlock()
}
func (s *recordingSink) types() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.events))
for _, e := range s.events {
out = append(out, e.typ)
}
return out
}
func (s *recordingSink) last() (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.events) == 0 {
return "", false
}
return s.events[len(s.events)-1].typ, true
}
// userMsg builds a text-only user message.
func userMsg(text string) model.Message {
return model.TextMessage(model.RoleUser, text)
}
// messageText returns the concatenated text of a message's text parts.
func messageText(m model.Message) string {
var out string
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out += c.Text
}
}
return out
}
// seedOrchestrator builds an orchestrator with a gated runner and a recording
// sink, both returned so the test can drive the chain and assert state events.
func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordingSink) {
sink := &recordingSink{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
Log: slog.Default(),
MaxPending: maxPending,
MaxTail: 20,
})
return o, sink
}
// readOnlyEnv returns the group-chat / stranger read-only memory lane.
func readOnlyEnv() loop.SessionEnv {
return loop.SessionEnv{MemWritable: false}
}
func TestSubmitIdleStartsImmediate(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, _ := seedOrchestrator(run, 5)
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
run.tickets <- struct{}{}
got := run.waitCalls(1)
if len(got) != 1 {
t.Fatalf("calls = %d, want 1", len(got))
}
if o.Busy("s1") {
t.Fatal("session should be idle after a completed turn")
}
}
func TestSubmitBusyPendingNotDropped(t *testing.T) {
t.Parallel()
run := newGatedRunner(4)
o, sink := seedOrchestrator(run, 5)
// Turn A starts and blocks (no ticket yet), so the session is busy.
a := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
if !a.Immediate {
t.Fatalf("A = %+v, want Immediate", a)
}
// B and C arrive while A is in flight: they must be queued, not dropped.
b := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv())
c := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv())
if !b.Accepted || !b.Pending || b.QueueDepth != 1 {
t.Fatalf("B = %+v, want Pending depth=1", b)
}
if !c.Accepted || !c.Pending || c.QueueDepth != 2 {
t.Fatalf("C = %+v, want Pending depth=2", c)
}
if o.QueueDepth("s1") != 2 {
t.Fatalf("queue depth = %d, want 2", o.QueueDepth("s1"))
}
// Release the chain: A, then B, then C. The emitted ack should include the
// pending depth, and the sink should have seen pending_ack events.
types := sink.types()
foundAck := false
for _, ty := range types {
if ty == EventPendingAck {
foundAck = true
}
}
if !foundAck {
t.Fatalf("no pending_ack in events: %v", types)
}
run.tickets <- struct{}{}
run.tickets <- struct{}{}
run.tickets <- struct{}{}
if n := len(run.waitCalls(3)); n != 3 {
t.Fatalf("total calls = %d, want 3 (A+B+C)", n)
}
if o.QueueDepth("s1") != 0 || o.Busy("s1") {
t.Fatalf("session should be idle and empty after drain: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
}
func TestSubmitBusyResumeCarriesPreviousContext(t *testing.T) {
t.Parallel()
run := newGatedRunner(3)
o, _ := seedOrchestrator(run, 5)
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv()); !sr.Pending {
t.Fatalf("B = %+v, want Pending", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("C = %+v, want Pending", sr)
}
run.tickets <- struct{}{} // A
run.tickets <- struct{}{} // B
run.tickets <- struct{}{} // C
got := run.waitCalls(3)
// A starts from a blank turn.
if len(got[0].messages) != 1 || messageText(got[0].messages[0]) != "A" {
t.Fatalf("A messages = %v, want [A]", messageTextList(got[0].messages))
}
// B must resume carrying A's assistant reply folded into the tail.
if !matchesText(got[1].messages, "A", "B") {
t.Fatalf("B messages = %v, want B to resume carrying the prior tail", messageTextList(got[1].messages))
}
// C must resume carrying A's and B's assistant replies.
if !matchesText(got[2].messages, "A", "C") {
t.Fatalf("C messages = %v, want C to resume carrying the prior tail", messageTextList(got[2].messages))
}
// B's env must be the one the message arrived with (read-only lane).
if got[1].env.MemWritable {
t.Fatal("B resumed with MemWritable=true, want the caller's read-only env")
}
}
// matchesText reports whether a resumed turn begins with the captured first user
// turn (so the prior context was carried over), ends with the expected new user
// turn, and contains at least one assistant reply in between.
func matchesText(msgs []model.Message, first, last string) bool {
if len(msgs) < 3 {
return false
}
if messageText(msgs[0]) != first {
return false
}
if messageText(msgs[len(msgs)-1]) != last {
return false
}
for i := 1; i < len(msgs)-1; i++ {
if msgs[i].Role == model.RoleAssistant {
return true
}
}
return false
}
func messageTextList(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, messageText(m))
}
return out
}
func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, _ := seedOrchestrator(run, 3) // tight queue
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
for i := 0; i < 3; i++ {
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("p%d", i))}, loop.OwnerEnv())
if !sr.Pending || sr.QueueDepth != i+1 {
t.Fatalf("pending %d = %+v, want Pending depth=%d", i, sr, i+1)
}
}
// Queue is at capacity (3), so the next message is refused, not dropped.
over := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv())
if over.Accepted || !over.PendingFull {
t.Fatalf("overflow = %+v, want PendingFull reject", over)
}
if o.QueueDepth("s1") != 3 {
t.Fatalf("queue depth = %d, want 3 (unchanged)", o.QueueDepth("s1"))
}
}
func TestInterruptCancelsTurnAndClearsPending(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("B = %+v, want Pending", sr)
}
// Release no ticket: A is blocked in flight. Interrupt cancels it and clears B.
dropped := o.Interrupt("s1")
if dropped != 1 {
t.Fatalf("dropped = %d, want 1", dropped)
}
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
t.Fatalf("session not idle after interrupt: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
// A new message after the interrupt must start fresh (Immediate), and the
// canceled chain must not have resumed B.
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("C = %+v, want Immediate after interrupt", sr)
}
run.tickets <- struct{}{}
got := run.waitCalls(1)
if len(got) != 1 {
t.Fatalf("calls = %d, want 1 (only C, not the canceled A or B)", len(got))
}
if messageText(got[0].messages[0]) != "C" {
t.Fatalf("only surviving call = %v, want the C turn", messageTextList(got[0].messages))
}
}
func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, _ := seedOrchestrator(run, 3)
const senders = 8
var wg sync.WaitGroup
accepted := make(chan SubmitResult, senders)
for i := 0; i < senders; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
accepted <- o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("m%d", i))}, loop.OwnerEnv())
}(i)
}
wg.Wait()
close(accepted)
immediate, pending, full := 0, 0, 0
for sr := range accepted {
switch {
case sr.Immediate:
immediate++
case sr.Pending:
pending++
case sr.PendingFull:
full++
default:
t.Fatalf("unexpected submit result: %+v", sr)
}
}
// With maxPending=3: exactly 1 turn runs immediately and up to 3 queue behind
// it; the remaining senders are refused at capacity rather than dropped.
if immediate != 1 {
t.Fatalf("immediate = %d, want 1", immediate)
}
if pending != 3 {
t.Fatalf("pending = %d, want 3", pending)
}
if full != senders-4 {
t.Fatalf("pendingFull = %d, want %d", full, senders-4)
}
if o.QueueDepth("s1") != 3 {
t.Fatalf("queue depth = %d, want 3", o.QueueDepth("s1"))
}
// Drain the chain: 1 running + 3 queued = 4 turns. No panic / no lost message.
for i := 0; i < 4; i++ {
run.tickets <- struct{}{}
}
if n := len(run.waitCalls(4)); n != 4 {
t.Fatalf("total calls = %d, want 4", n)
}
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
t.Fatalf("session should be idle and empty after drain")
}
}
func TestSubmitEmptyRejected(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, _ := seedOrchestrator(run, 5)
sr := o.Submit(context.Background(), "s1", nil, loop.OwnerEnv())
if sr.Accepted || !sr.Empty {
t.Fatalf("empty submit = %+v, want Empty reject", sr)
}
if run.count() != 0 {
t.Fatalf("runner invoked for an empty turn")
}
}
func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
// Idle → synchronous run returns the reply immediately. Push a ticket so the
// gated runner's blocking select resolves during the synchronous wait.
run.tickets <- struct{}{}
sr, res := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("sync idle = %+v, want Accepted+Immediate", sr)
}
if res == nil {
t.Fatal("sync idle should return a result")
}
// Hold a busy turn in flight via an async Submit (blocked, no ticket), then a
// sync submit must return a Pending ack without blocking and without a result.
if sr2 := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr2.Immediate {
t.Fatalf("async A = %+v, want Immediate", sr2)
}
sr3, res3 := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
if !sr3.Accepted || !sr3.Pending {
t.Fatalf("sync busy = %+v, want Pending", sr3)
}
if res3 != nil {
t.Fatal("sync busy should not return a result (must not block)")
}
run.tickets <- struct{}{} // let A finish so the orchestrator is idle again
}
func TestInterruptAllClearsEverySession(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
o.Submit(context.Background(), "s2", []model.Message{userMsg("X")}, loop.OwnerEnv())
if got := o.InterruptAll(); got != 1 {
t.Fatalf("InterruptAll dropped = %d, want 1 (B)", got)
}
if o.Busy("s1") || o.Busy("s2") {
t.Fatal("all sessions should be idle after InterruptAll")
}
}
func TestSetModeBusyAndIdle(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, sink := seedOrchestrator(run, 5)
o.SetMode("s1", ModeExecuting)
if !o.Busy("s1") || o.ModeOf("s1") != ModeExecuting {
t.Fatalf("after executing: busy=%v mode=%q", o.Busy("s1"), o.ModeOf("s1"))
}
o.SetMode("s1", ModeIdle)
if o.Busy("s1") {
t.Fatal("session must be idle after SetMode(idle)")
}
if ty, ok := sink.last(); !ok || ty != EventSessionIdle {
t.Fatalf("last event = %q, want session_idle", ty)
}
}
+63 -25
View File
@@ -74,14 +74,15 @@ type hub struct {
runner Chatter
auditor SubagentAuditor
taskOutputs TaskOutputStore
voice VoiceService
streaming bool
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
voice VoiceService
streaming bool
orchestrator *Orchestrator
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
}
// newHub builds a hub bound to the server's dependencies.
@@ -124,11 +125,15 @@ func (h *hub) start() {
}
}
// close cancels the bus subscription and shuts every live connection down.
// close cancels the bus subscription, interrupts every orchestrator session (so
// no background turn outlives the hub), and shuts every live connection down.
func (h *hub) close() {
if h.cancel != nil {
h.cancel()
}
if h.orchestrator != nil {
h.orchestrator.InterruptAll()
}
h.mu.Lock()
conns := make([]*wsConn, 0, len(h.conns))
for c := range h.conns {
@@ -205,20 +210,19 @@ func (h *hub) relay(ctx context.Context, e bus.Event) error {
return nil
}
// streamChat runs one agent turn for a chat trigger and broadcasts the turn
// terminus (stream_end) with a fresh turnId; on the non-streaming fallback path
// it also emits a single stream_chunk. When the loop is streaming, the per-event
// deltas arrive via the OnStream→bus→relay path as structured message.*/
// thinking.*/tool_call.* events. It runs on its own goroutine so a slow model
// streamChat is the ws.chat trigger entry point. It decodes the request, and
// routes the turn through the session orchestrator when one is wired (the
// non-blocking busy/pending/resume path of design/15); otherwise it falls back to
// the legacy direct run so a hub built without an orchestrator keeps the exact
// stream_chunk/stream_end contract. It runs on its own goroutine so a slow model
// call does not stall the bus subscription worker.
func (h *hub) streamChat(ctx context.Context, payload any) {
var req chatStreamRequest
if b, err := json.Marshal(payload); err == nil {
_ = json.Unmarshal(b, &req)
}
turnID := newTurnID()
if h.runner == nil || (strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "") {
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
return
}
if req.SessionID == "" {
@@ -226,21 +230,55 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
}
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
if err != nil {
h.log.Error("ws chat run", "err", err, "session", req.SessionID)
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})
msgs := userChatMessages(req.Text, req.Image)
if h.orchestrator != nil {
_ = h.orchestrator.Submit(context.Background(), req.SessionID, msgs, loop.OwnerEnv())
return
}
// When the loop is streaming (OnStream wired), the structured deltas already
// fan out as message.*/thinking.*/tool_call.* events through the
// OnStream→bus→relay path; a second whole-text chunk would duplicate the
// tail. The non-streaming path (or a stub runner) still emits one chunk so
// the stream contract holds in tests.
_, _ = h.runTurn(ctx, req.SessionID, msgs, loop.OwnerEnv())
}
// runTurn executes one agent turn and broadcasts its output stream. It is the
// unit both the orchestrator (via its injected TurnRunner) and the legacy
// fallback path share, so a turn always emits the same stream_chunk/stream_end
// events regardless of who drives it. When the loop is streaming (OnStream wired)
// the structured deltas already fan out as message.*/thinking.*/tool_call.*
// events through the OnStream→bus→relay path, so only the non-streaming path
// emits a single whole-text chunk to hold the stream contract.
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
turnID := newTurnID()
if h.runner == nil || len(msgs) == 0 {
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
return &loop.Result{StopReason: "idle"}, nil
}
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs)
if err != nil {
h.log.Error("chat run", "err", err, "session", sessionID)
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})
return nil, err
}
if !h.streaming {
h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"delta": res.Text}})
}
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
return res, nil
}
// handleInterrupt routes a client "interrupt" frame (design/15 §3) to the
// orchestrator, canceling the in-flight turn and clearing the pending queue for
// the requested session. It is a no-op when no orchestrator is wired.
func (h *hub) handleInterrupt(payload json.RawMessage) {
if h.orchestrator == nil {
return
}
var req struct {
SessionID string `json:"sessionId"`
}
_ = json.Unmarshal(payload, &req)
if req.SessionID == "" {
return
}
h.orchestrator.Interrupt(req.SessionID)
}
// broadcastStream maps a streamed model.StreamEvent to its downstream WS event
+4
View File
@@ -14,6 +14,10 @@ func (c *wsConn) route(in wsInbound) {
c.publishPublic("ws.mode_switch", rawData(in.Data))
case "chat":
c.publishPublic(wsChatSubject, rawData(in.Data))
case "interrupt":
// design/15 §3: user says "停下/算了" → cancel the in-flight turn and
// clear the session's pending queue, so the next message starts fresh.
c.h.handleInterrupt(in.Data)
case "voice_input":
// Voice routes through the injected STT→chat→TTS pipeline. Without a
// voice service it replies with a clear error rather than dropping the