feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env) - 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」 - 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事) - 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了) - 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过 - 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
This commit is contained in:
1 parent
a057a0f61f
commit
34cef95e17
25 files changed
+1741
-14
No files matched your search
@@ -9,6 +9,8 @@ import { FlowGraphView } from "./FlowGraph";
|
||||
import { adminBackthink } from "../../lib/api";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { BACKTHINK_SESSION_ID } from "../../store/sessionCache";
|
||||
import { BackthinkUrgencyTags } from "./BackthinkUrgencyTags";
|
||||
import { QuietHoursFactsCard } from "./ConfigFactsCards";
|
||||
|
||||
/** 推送路由的中文标签(后端 PushResult.Route 的取值域)。未知取值原样显示,
|
||||
* 绝不猜一个更好听的词。 */
|
||||
@@ -44,6 +46,7 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
<Tag tone={item.pushed ? "ok" : "neutral"}>{item.pushed ? "已推送" : "未推送"}</Tag>
|
||||
{item.route ? <Tag tone={pushRouteTone(item.route)}>{PUSH_ROUTE_LABEL[item.route] ?? item.route}</Tag> : null}
|
||||
{item.bypassed ? <Tag tone="accent">事件直发</Tag> : null}
|
||||
<BackthinkUrgencyTags item={item} />
|
||||
<span className="ml-auto">
|
||||
<Button size="sm" variant="ghost" disabled={item.pushed} onClick={() => markPushed(item.id)}>
|
||||
{item.pushed ? "已推送" : "推送结果"}
|
||||
@@ -104,7 +107,7 @@ export function BackthinkModule(): React.ReactNode {
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="后台思考与主动"
|
||||
subtitle="触发源 / 时间 / 结论 / 是否打扰 / 推送结果 · 打扰决策可解释"
|
||||
subtitle="触发源 / 时间 / 结论 / 是否打扰 / 推送结果 / 紧急度与安静时段穿透 · 打扰决策可解释"
|
||||
actions={
|
||||
<Button
|
||||
variant="soft"
|
||||
@@ -122,6 +125,8 @@ export function BackthinkModule(): React.ReactNode {
|
||||
<Notice tone="warn">对后台思考的打断 / 重派需后端 M10+ 能力接入,当前未启用;此处仅展示已产生的思考记录。</Notice>
|
||||
) : null}
|
||||
|
||||
<QuietHoursFactsCard />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>全部</FilterPill>
|
||||
<FilterPill active={filter === "notify"} onClick={() => setFilter("notify")}>需打扰</FilterPill>
|
||||
@@ -138,7 +143,9 @@ export function BackthinkModule(): React.ReactNode {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 两条路径同名同义:历史拉取取 AdminBackthinkItem 的同名键,实时帧取后端原始 PushResult 的 Route / Detail / Bypassed(解析器已读,见 lib/wsParse.ts#parseBackthinkItem,兼容 camelCase 变体),因此本次会话新到的行同样显示推送路由与推送说明,不再只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
|
||||
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 两条路径同名同义:历史拉取取 AdminBackthinkItem 的同名键,实时帧取后端原始 PushResult 的 Route / Detail / Bypassed(解析器已读,见 lib/wsParse.ts#parseBackthinkItem,兼容 camelCase 变体),因此本次会话新到的行同样显示推送路由与推送说明,不再只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。
|
||||
<br />
|
||||
新增的「穿透安静时段 / 紧急度」标记(契约 v0.68):穿透 = 这一轮落在安静时段窗口内、仍然被放行发出(只有 high 及以上档位做得到);紧急度是后端 urgency 档位,且只是「档位下限」(显示「≥ 高」= 至少 high,记录里分不出 high 与 critical)。显示「档位未知」是如实的:轮次不在安静时段窗口内时,持久化记录不保存档位 —— 它不是「例常」。⚠️ 实时帧走的是 bus 上原始的 backthink.Record,不带这两个字段,因此本次会话新到的行先显示「档位未知」,重开页面后由历史拉取补上真值。</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// 后台思考行「穿透安静时段 / 紧急度」标记的**真实渲染**测试(契约 v0.68 §4.10(b))。
|
||||
//
|
||||
// 为什么需要它:后端明明标了「这一轮穿透了安静时段」,面板不画出来就等于没做;而
|
||||
// 「档位未知」与「例常」**必须**画得不一样 —— 把未知画成事实正是这次要避免的坑。
|
||||
//
|
||||
// 跑法(同 channels/accountRow.render.test.ts):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/BackthinkUrgencyTags.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-backthink-urgency.test.cjs \
|
||||
// && node /tmp/cyrene-backthink-urgency.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { BackthinkItem } from "../../lib/types";
|
||||
import { BackthinkUrgencyTags } from "./BackthinkUrgencyTags";
|
||||
import { URGENCY_UNKNOWN_LABEL, QUIET_HOURS_BYPASS_LABEL, urgencyTag, quietHoursBypassTag } from "./backthinkUrgency";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
function item(over: Partial<BackthinkItem>): BackthinkItem {
|
||||
return { id: "1", trigger: "iot.device.smoke", at: 0, ...over };
|
||||
}
|
||||
|
||||
function htmlFor(over: Partial<BackthinkItem>): string {
|
||||
return renderToStaticMarkup(createElement(BackthinkUrgencyTags, { item: item(over) }));
|
||||
}
|
||||
|
||||
// ── ① 穿透行:安静时段内被放行 ⇒ 同时出「穿透安静时段」与档位下限「≥ 高」 ──────
|
||||
const bypassed = htmlFor({ urgency: "warning", quietHoursBypassed: true, shouldNotify: true, pushed: true });
|
||||
ok(has(bypassed, QUIET_HOURS_BYPASS_LABEL), "穿透的行必须画出「穿透安静时段」");
|
||||
ok(has(bypassed, "紧急(≥ 高)"), "档位下限必须显示为「≥ 高」,不能假装是精确档位");
|
||||
ok(!has(bypassed, URGENCY_UNKNOWN_LABEL), "有档位的行不得同时显示「档位未知」");
|
||||
|
||||
// ── ② 安静时段内被扣下 ⇒ 例常,且**不得**画成穿透 ──────────────────────────
|
||||
const held = htmlFor({ urgency: "routine", shouldNotify: false });
|
||||
ok(has(held, "例常"), "被扣下的轮次显示「例常」");
|
||||
ok(!has(held, QUIET_HOURS_BYPASS_LABEL), "被扣下的轮次绝不能显示穿透");
|
||||
|
||||
// ── ③ 键缺席(窗口外的轮次 / 实时帧)⇒ 显示「档位未知」,不是「例常」 ────────
|
||||
const unknown = htmlFor({ shouldNotify: true, pushed: true });
|
||||
ok(has(unknown, `>${URGENCY_UNKNOWN_LABEL}<`), "没有档位时标签正是「档位未知」");
|
||||
ok(!has(unknown, ">例常<"), "标签绝不能是「例常」——那就是把猜当事实");
|
||||
ok(!has(unknown, QUIET_HOURS_BYPASS_LABEL), "没有穿透标记时不画穿透");
|
||||
|
||||
// ── ④ 后端加了本面板不认识的档位 ⇒ 原样显示,不猜更好听的词 ─────────────────
|
||||
const exotic = urgencyTag({ urgency: "emergency" });
|
||||
ok(exotic.label === "emergency", "未知档位原样显示");
|
||||
ok(!has(htmlFor({ urgency: "emergency" }), ">例常<"), "未知档位不得被解释成「例常」");
|
||||
|
||||
// ── ⑤ 安全域档位有独立标签与色调 ────────────────────────────────────────────
|
||||
ok(has(htmlFor({ urgency: "critical", quietHoursBypassed: true }), "紧急·安全域"), "critical 显示安全域标签");
|
||||
ok(urgencyTag({ urgency: "critical" }).tone === "err", "critical 用 err 色调");
|
||||
ok(urgencyTag({ urgency: "warning" }).tone === "warn", "warning(下限)用 warn 色调");
|
||||
ok(urgencyTag({ urgency: "routine" }).tone === "neutral", "routine 用中性色调");
|
||||
ok(urgencyTag({}).label === URGENCY_UNKNOWN_LABEL, "缺档位时标签是未知");
|
||||
ok(quietHoursBypassTag({ quietHoursBypassed: false }) === null, "显式 false 不画穿透(后端只发 true,双向都不画)");
|
||||
ok(quietHoursBypassTag({}) === null, "键缺席不画穿透");
|
||||
|
||||
// ── ⑥ 两个标记同源可追:提示文字解释「为什么是这个档位」 ─────────────────────
|
||||
ok(has(bypassed, "title="), "标记带悬停说明(title),让人能追到判据");
|
||||
ok(has(urgencyTag({ urgency: "warning" }).hint, "至少 high") || has(urgencyTag({ urgency: "warning" }).hint, "high"), "档位下限的说明必须点明它只是下限");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`BackthinkUrgencyTags.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { BackthinkItem } from "../../lib/types";
|
||||
import { Tag } from "../../components/primitives";
|
||||
import { quietHoursBypassTag, urgencyTag } from "./backthinkUrgency";
|
||||
|
||||
/** 后台思考行的「穿透安静时段 + 紧急度」标记(Miuix Tag)。
|
||||
*
|
||||
* 为什么单独抽成组件:它是**画出来才算数**的一条(后端字段对了、面板没显示等于没
|
||||
* 做),因此需要一个真实渲染的测试盯着它 —— 见 BackthinkUrgencyTags.render.test.ts。
|
||||
*
|
||||
* ⚠️ 实时帧(bus 上原始 `backthink.Record`)目前不带档位 ⇒ 本次会话新到的行显示
|
||||
* 「档位未知」,重开页面后由历史拉取(`GET /admin/backthink`)补上真值。 */
|
||||
export function BackthinkUrgencyTags({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
const urgency = urgencyTag(item);
|
||||
const bypass = quietHoursBypassTag(item);
|
||||
return (
|
||||
<>
|
||||
{bypass ? (
|
||||
<Tag tone={bypass.tone} title={bypass.hint}>
|
||||
{bypass.label}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag tone={urgency.tone} title={urgency.hint}>
|
||||
{urgency.label}
|
||||
</Tag>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 配置只读事实卡的**真实渲染**测试。
|
||||
//
|
||||
// 为什么需要它:这次的产品缺口是「面板看不见运行实例实际在用什么值」,而修好之后的
|
||||
// 关键断言是**文案**——安静时段必须按新语义写「只服务『定时主动思考 → 主动搭话』,
|
||||
// 不静默回复」,绝不能写成已废弃的「夜间全静默」。文案对不对只有画出来才算数。
|
||||
//
|
||||
// 跑法(与 channels/accountRow.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/ConfigFactsCards.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-config-facts.render.test.cjs \
|
||||
// && node /tmp/cyrene-config-facts.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminConfigFacts } from "../../lib/types";
|
||||
import { MemoryTidyFactsBody, QuietHoursFactsBody } from "./ConfigFactsCards";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
const FACTS: AdminConfigFacts = {
|
||||
facts: [
|
||||
{ key: "MEMORY_TIDY_ENABLED", value: "true", default: "true", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_INTERVAL_SEC", value: "3600", default: "86400", isDefault: false },
|
||||
{ key: "MEMORY_TIDY_QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true },
|
||||
{ key: "QUIET_START_HOUR", value: "1", default: "23", isDefault: false },
|
||||
{ key: "QUIET_END_HOUR", value: "5", default: "7", isDefault: false },
|
||||
],
|
||||
quietHours: { startHour: 1, endHour: 5, disabled: false, label: "01:00-05:00" },
|
||||
};
|
||||
|
||||
// ── 记忆整理卡:7 个真键名 + 生效值 + 默认/已改 ──────────────────────────────
|
||||
const tidyHtml = renderToStaticMarkup(createElement(MemoryTidyFactsBody, { facts: FACTS }));
|
||||
for (const key of [
|
||||
"MEMORY_TIDY_ENABLED",
|
||||
"MEMORY_TIDY_INTERVAL_SEC",
|
||||
"MEMORY_TIDY_QUIET_START_HOUR",
|
||||
"MEMORY_TIDY_QUIET_END_HOUR",
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD",
|
||||
"MEMORY_TIDY_SCAN_LIMIT",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT",
|
||||
]) {
|
||||
ok(has(tidyHtml, key), `记忆整理卡必须列出真键名 ${key}(用户照着它改 .env)`);
|
||||
}
|
||||
ok(has(tidyHtml, "86400") === false && has(tidyHtml, "3600 秒"), "显示的是**生效值** 3600 秒");
|
||||
ok(has(tidyHtml, "已改"), "改过的键必须标「已改」");
|
||||
ok((tidyHtml.match(/默认/g) ?? []).length >= 6, "未改的键标「默认」");
|
||||
ok(has(tidyHtml, "只产建议"), "必须说清整理只产建议、不自动改记忆");
|
||||
ok(has(tidyHtml, "两端都为 0 = 未填"), "必须说清低峰窗口的 0/0 = 未填(否则用户会以为 0/0 是关窗口)");
|
||||
|
||||
// ── 安静时段卡:新语义文案(不得出现「夜间全静默」) ────────────────────────
|
||||
const quietHtml = renderToStaticMarkup(createElement(QuietHoursFactsBody, { facts: FACTS }));
|
||||
ok(has(quietHtml, "01:00-05:00"), "窗口摘要用后端给的口径");
|
||||
ok(has(quietHtml, "窗口生效"), "窗口生效态如实标注");
|
||||
ok(has(quietHtml, "安静时段只服务"), "必须点明它服务的对象");
|
||||
ok(has(quietHtml, "定时主动思考"), "必须点明「定时主动思考 → 主动搭话」");
|
||||
ok(has(quietHtml, "不会因为到了安静时段就被静默"), "必须说清回复侧不被静默(新语义)");
|
||||
ok(!has(quietHtml, "夜间全静默"), "绝不得写成「夜间全静默」(已废弃的旧实现语义)");
|
||||
ok(has(quietHtml, "QUIET_START_HOUR") && has(quietHtml, "QUIET_END_HOUR"), "列出真键名");
|
||||
ok(has(quietHtml, "1 时") && has(quietHtml, "5 时"), "显示进程生效的钟点");
|
||||
ok(has(quietHtml, "已改"), "被改过的窗口钟点标「已改」");
|
||||
|
||||
// ── 关闭态的窗口:必须说「从不生效」,不能画成假窗口 ────────────────────────
|
||||
const disabledHtml = renderToStaticMarkup(
|
||||
createElement(QuietHoursFactsBody, {
|
||||
facts: { ...FACTS, quietHours: { startHour: 23, endHour: 23, disabled: true, label: "已关闭" } },
|
||||
}),
|
||||
);
|
||||
ok(has(disabledHtml, "窗口关闭"), "关闭态必须显示「窗口关闭」");
|
||||
ok(has(disabledHtml, "从不生效"), "关闭态的摘要必须说「从不生效」");
|
||||
|
||||
// ── 两端写 0(未填 ⇒ 运行时回落):必须在面板上说明,不能显示成「已关闭」 ────
|
||||
const fallbackHtml = renderToStaticMarkup(
|
||||
createElement(QuietHoursFactsBody, {
|
||||
facts: {
|
||||
...FACTS,
|
||||
quietHours: { startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00", fallbackToDefault: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
ok(has(fallbackHtml, "运行时回落到默认窗口"), "0/0 的回落必须在面板上说明");
|
||||
ok(has(fallbackHtml, "窗口生效"), "回落之后窗口仍然生效,不得画成「窗口关闭」");
|
||||
ok(!has(fallbackHtml, "从不生效"), "回落不得被显示成「从不生效」");
|
||||
|
||||
// ── 缺事实:渲染「未报告」,不渲染 0 / 默认 ────────────────────────────────
|
||||
const partialHtml = renderToStaticMarkup(
|
||||
createElement(MemoryTidyFactsBody, {
|
||||
facts: { ...FACTS, facts: FACTS.facts.filter((f) => f.key !== "MEMORY_TIDY_SCAN_LIMIT") },
|
||||
}),
|
||||
);
|
||||
ok(has(partialHtml, "未报告"), "后端没给的键必须渲染「未报告」");
|
||||
ok(has(partialHtml, "MEMORY_TIDY_SCAN_LIMIT"), "缺的键仍然列出键名(让人知道少了什么)");
|
||||
|
||||
// ── 没有数据时不渲染任何编造的默认行 ────────────────────────────────────────
|
||||
const emptyHtml = renderToStaticMarkup(createElement(QuietHoursFactsBody, { facts: null }));
|
||||
ok(emptyHtml === "", "没有数据时不渲染任何事实行(卡片壳负责 loading/error)");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`ConfigFactsCards.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { adminConfigFacts } from "../../lib/api";
|
||||
import type { AdminConfigFacts } from "../../lib/types";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { Notice } from "../components";
|
||||
import { Surface, Tag } from "../../components/primitives";
|
||||
import {
|
||||
memoryTidyRows,
|
||||
quietHoursPurpose,
|
||||
quietHoursRows,
|
||||
quietHoursSummary,
|
||||
type FactRow,
|
||||
} from "./configFacts";
|
||||
|
||||
/**
|
||||
* 配置**只读事实卡**(切片 P3,契约 v0.68 §4.10(a)):把记忆整理 7 键与安静时段
|
||||
* 的**进程生效值**摊在面板上,用户不必去猜 `.env` 到底有没有生效。
|
||||
*
|
||||
* 它刻意是**只读**:这些键的值来自 `.env`(生产用 systemd/env_file),面板没有
|
||||
* 写回通道,因此不做输入框 —— 那会变成「谎称可调」(与面板既有的诚实边界一致)。
|
||||
* 网关**不读 `.env`**,回的是 `config.Load` 解析后的生效值,所以这里显示的就是
|
||||
* 运行实例真正在用的数。
|
||||
*
|
||||
* 展示与取数刻意分层:`*Body` 只吃数据(可用真实渲染测试直接喂 fixture),
|
||||
* 卡片组件负责取数 + loading/error 呈现。
|
||||
*/
|
||||
|
||||
/** FactLine 是一行事实:中文说明 + 真键名(mono)+ 生效值 + 「默认/已改/未报告」。 */
|
||||
function FactLine({ row }: { row: FactRow }): React.ReactNode {
|
||||
const tone = row.state === "已改" ? "warn" : row.state === "未报告" ? "err" : "neutral";
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0">
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">{row.label}</span>
|
||||
<span className="ml-2 font-mono text-[11px] text-[var(--mu-text-lo)]">{row.key}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-mono text-sm text-[var(--mu-text-hi)]">{row.value}</span>
|
||||
<Tag tone={tone}>{row.state}</Tag>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** FactsLines 渲染一组事实行。 */
|
||||
function FactsLines({ rows }: { rows: FactRow[] }): React.ReactNode {
|
||||
return (
|
||||
<div className="divide-y divide-[var(--mu-glass-border)]">
|
||||
{rows.map((row) => (
|
||||
<FactLine key={row.key} row={row} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** FactsState 统一「读取中 / 读取失败」的呈现(两个卡片共用,避免各写一套)。 */
|
||||
function FactsState({ loading, error }: { loading: boolean; error: string | null }): React.ReactNode {
|
||||
if (loading) return <span className="text-xs text-[var(--mu-text-lo)]">读取配置事实…</span>;
|
||||
if (error) return <Notice tone="err">读取配置事实失败:{error}(下面的「未报告」因此不代表后端没配)</Notice>;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** useFacts 拉一次配置事实(两个卡片分属不同页面,各拉各的,互不共享状态)。 */
|
||||
function useFacts(): { facts: AdminConfigFacts | null; loading: boolean; error: string | null } {
|
||||
const { data, loading, error } = useAsyncData(() => adminConfigFacts(), []);
|
||||
return { facts: data, loading, error };
|
||||
}
|
||||
|
||||
/** MemoryTidyFactsBody 是「记忆整理」事实卡的纯展示体(测试直接喂数据)。 */
|
||||
export function MemoryTidyFactsBody({ facts }: { facts: AdminConfigFacts | null }): React.ReactNode {
|
||||
if (!facts) return null;
|
||||
return (
|
||||
<>
|
||||
<FactsLines rows={memoryTidyRows(facts)} />
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
整理是低峰期的后台任务,只产建议、绝不自动合并 / 删除 / 改写任何记忆,也不推送、不进 chat 上下文。
|
||||
低峰窗口的写法:两端相等的非 0 钟点 = 关闭窗口;两端都为 0 = 未填(运行时回落到默认 23:00-07:00)。
|
||||
卡是只读的:改值请编辑 <span className="font-mono">.env</span> 后重启服务。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** QuietHoursFactsBody 是「安静时段」事实卡的纯展示体,文案按主人裁决 10 的新语义。 */
|
||||
export function QuietHoursFactsBody({ facts }: { facts: AdminConfigFacts | null }): React.ReactNode {
|
||||
if (!facts) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-[var(--mu-text-hi)]">{quietHoursSummary(facts.quietHours)}</span>
|
||||
<Tag tone={facts.quietHours.disabled ? "warn" : "ok"}>
|
||||
{facts.quietHours.disabled ? "窗口关闭" : "窗口生效"}
|
||||
</Tag>
|
||||
</div>
|
||||
<FactsLines rows={quietHoursRows(facts)} />
|
||||
<p className="text-xs text-[var(--mu-text-mid)]">{quietHoursPurpose()}</p>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
只读事实(值 = 进程生效值,不是 <span className="font-mono">.env</span> 原文):改窗口请编辑{" "}
|
||||
<span className="font-mono">QUIET_START_HOUR</span> / <span className="font-mono">QUIET_END_HOUR</span> 后重启服务。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 记忆管理页的「记忆整理」只读事实卡。 */
|
||||
export function MemoryTidyFactsCard(): React.ReactNode {
|
||||
const { facts, loading, error } = useFacts();
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">记忆整理(只读事实)</h3>
|
||||
<Tag tone="neutral">进程生效值</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/config-facts</span>
|
||||
</div>
|
||||
<FactsState loading={loading} error={error} />
|
||||
<MemoryTidyFactsBody facts={facts} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台思考页的「安静时段」只读事实卡。 */
|
||||
export function QuietHoursFactsCard(): React.ReactNode {
|
||||
const { facts, loading, error } = useFacts();
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">安静时段(只读事实)</h3>
|
||||
<Tag tone="neutral">进程生效值</Tag>
|
||||
</div>
|
||||
<FactsState loading={loading} error={error} />
|
||||
<QuietHoursFactsBody facts={facts} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -184,6 +185,8 @@ export function MemoriesModule(): React.ReactNode {
|
||||
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
|
||||
</Surface>
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<Notice>
|
||||
编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 手动添加 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。
|
||||
</Notice>
|
||||
|
||||
@@ -165,8 +165,12 @@ export function SystemModule(): React.ReactNode {
|
||||
<KeyValueRow label="ADMIN_USERNAME" value="admin" mono />
|
||||
<KeyValueRow label="ADMIN_PASSWORD" value="••••••••" mono />
|
||||
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
|
||||
以上为 <span className="font-mono">.env.example</span> 的默认值(面板无 env 读取端点,不是运行实例的实时值);
|
||||
改值请编辑 <span className="font-mono">.env</span> 后重启服务。
|
||||
以上为 <span className="font-mono">.env.example</span> 的默认值(这几条没有只读读取端点,不是运行实例的实时值;
|
||||
带凭据的键面板刻意不回值)。改值请编辑 <span className="font-mono">.env</span> 后重启服务。
|
||||
</p>
|
||||
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
|
||||
要看运行实例的实时生效值,去两处只读事实卡:记忆管理页的「记忆整理」与后台思考页的「安静时段」
|
||||
(<span className="font-mono">GET /api/v1/admin/config-facts</span>,契约 v0.68)。
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { BackthinkItem } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* 后台思考行「紧急度 / 穿透安静时段」标记的**纯逻辑**(契约 v0.68 §4.10(b))。
|
||||
*
|
||||
* 后端 `urgency` 的语义是**存档可证的档位下限**(`warning` = 至少 high 档,可能更
|
||||
* 高),不是精确档位;而窗口之外的轮次存档里没有档位,键**缺席**。因此本层:
|
||||
* - 缺席 ⇒ 显示「档位未知」(**不是**「例常」——把未知画成事实就是这次要避免的坑);
|
||||
* - 未知取值 ⇒ 原样显示后端给的词,不猜一个更好听的;
|
||||
* - `quietHoursBypassed` 只在 true 时渲染「穿透安静时段」。
|
||||
*/
|
||||
|
||||
export type UrgencyTagTone = "ok" | "warn" | "err" | "neutral" | "accent";
|
||||
|
||||
export interface UrgencyTag {
|
||||
label: string;
|
||||
tone: UrgencyTagTone;
|
||||
/** 悬停说明(为什么是这个档位 / 为什么未知),给人看的口径。 */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** 档位未知时的标签(键缺席 = 后端没有这个事实)。 */
|
||||
export const URGENCY_UNKNOWN_LABEL = "档位未知";
|
||||
|
||||
/** 穿透安静时段的标签。 */
|
||||
export const QUIET_HOURS_BYPASS_LABEL = "穿透安静时段";
|
||||
|
||||
/** urgencyTag 把后端 `urgency` 映射成标签;键缺席时给「档位未知」。 */
|
||||
export function urgencyTag(item: Pick<BackthinkItem, "urgency">): UrgencyTag {
|
||||
const level = item.urgency;
|
||||
switch (level) {
|
||||
case "routine":
|
||||
return { label: "例常", tone: "neutral", hint: "安静时段内被扣下的轮次:档位低于 high,不穿透" };
|
||||
case "warning":
|
||||
return {
|
||||
label: "紧急(≥ 高)",
|
||||
tone: "warn",
|
||||
hint: "安静时段内仍被放行 ⇒ 档位至少 high;记录里无法区分 high 与 critical,故显示下限",
|
||||
};
|
||||
case "critical":
|
||||
return { label: "紧急·安全域", tone: "err", hint: "安全域事件(可穿透工作/忙碌与安静时段)" };
|
||||
case undefined:
|
||||
case "":
|
||||
return {
|
||||
label: URGENCY_UNKNOWN_LABEL,
|
||||
tone: "neutral",
|
||||
hint: "这一轮的档位在存档里不可证(轮次不在安静时段窗口内,记录不保存档位)——不是「例常」",
|
||||
};
|
||||
default:
|
||||
return { label: level, tone: "neutral", hint: "后端给了本面板不认识的档位,原样显示" };
|
||||
}
|
||||
}
|
||||
|
||||
/** quietHoursBypassTag 只在后端确实标记了穿透时返回标签。 */
|
||||
export function quietHoursBypassTag(item: Pick<BackthinkItem, "quietHoursBypassed">): UrgencyTag | null {
|
||||
if (item.quietHoursBypassed !== true) return null;
|
||||
return {
|
||||
label: QUIET_HOURS_BYPASS_LABEL,
|
||||
tone: "accent",
|
||||
hint: "这一轮落在安静时段窗口内、但仍被放行发出(档位 ≥ high)",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 配置只读事实面(契约 v0.68 §4.10(a))的**纯逻辑**测试:形状 → 文案的映射。
|
||||
// 这里盯的是三条不变量:值只取后端给的生效值、默认/已改只由 isDefault 决定、
|
||||
// 后端没给的键如实显示「未报告」而不是编一个 0。
|
||||
//
|
||||
// 跑法(项目无前端测试运行器,用 vite 自带的 esbuild;见同目录既有测试):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/configFacts.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --outfile=/tmp/cyrene-config-facts.test.mjs \
|
||||
// && node /tmp/cyrene-config-facts.test.mjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会类型检查本文件,因此它不 import node:*。
|
||||
|
||||
import type { AdminConfigFacts } from "../../lib/types";
|
||||
import {
|
||||
FACT_MISSING_VALUE,
|
||||
MEMORY_TIDY_KEYS,
|
||||
QUIET_HOURS_KEYS,
|
||||
factRows,
|
||||
memoryTidyRows,
|
||||
quietHoursPurpose,
|
||||
quietHoursRows,
|
||||
quietHoursSummary,
|
||||
} from "./configFacts";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
/** 生效值全为默认的常态响应(后端在默认部署下会这么回)。 */
|
||||
const DEFAULTS: AdminConfigFacts = {
|
||||
facts: [
|
||||
{ key: "MEMORY_TIDY_ENABLED", value: "true", default: "true", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_INTERVAL_SEC", value: "86400", default: "86400", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true },
|
||||
{ key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true },
|
||||
{ key: "QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
|
||||
{ key: "QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
|
||||
],
|
||||
quietHours: { startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00" },
|
||||
};
|
||||
|
||||
// ── ① 值就是后端给的生效值(含单位),不重算 ─────────────────────────────────
|
||||
const tidy = memoryTidyRows(DEFAULTS);
|
||||
same(tidy.length, 7, "记忆整理恰好 7 行(后端的 7 个键)");
|
||||
same(
|
||||
tidy.map((r) => r.key),
|
||||
[...MEMORY_TIDY_KEYS],
|
||||
"键序与后端事实表一致",
|
||||
);
|
||||
const interval = tidy.find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC");
|
||||
same(interval?.value, "86400 秒", "周期带单位显示,数值原样取后端");
|
||||
same(interval?.state, "默认", "isDefault=true ⇒ 显示「默认」");
|
||||
const threshold = tidy.find((r) => r.key === "MEMORY_TIDY_RECALL_THRESHOLD");
|
||||
same(threshold?.value, "0.8", "比例值原样显示(后端已归一化)");
|
||||
|
||||
// ── ② 「已改」只由 isDefault 决定(不反推 .env) ─────────────────────────────
|
||||
const changed: AdminConfigFacts = {
|
||||
...DEFAULTS,
|
||||
facts: DEFAULTS.facts.map((f) =>
|
||||
f.key === "MEMORY_TIDY_INTERVAL_SEC" ? { ...f, value: "3600", isDefault: false } : f,
|
||||
),
|
||||
};
|
||||
const changedRow = memoryTidyRows(changed).find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC");
|
||||
same(changedRow?.value, "3600 秒", "改过的值如实显示");
|
||||
same(changedRow?.state, "已改", "isDefault=false ⇒ 显示「已改」");
|
||||
same(
|
||||
memoryTidyRows(changed).filter((r) => r.state === "已改").length,
|
||||
1,
|
||||
"只有真的改过的那一行是「已改」",
|
||||
);
|
||||
|
||||
// ── ③ 后端没给的键如实显示「未报告」,绝不编值 ──────────────────────────────
|
||||
const partial: AdminConfigFacts = {
|
||||
facts: DEFAULTS.facts.filter((f) => f.key !== "MEMORY_TIDY_SCAN_LIMIT"),
|
||||
quietHours: DEFAULTS.quietHours,
|
||||
};
|
||||
const missing = memoryTidyRows(partial).find((r) => r.key === "MEMORY_TIDY_SCAN_LIMIT");
|
||||
same(missing?.value, FACT_MISSING_VALUE, "缺的键显示「未报告」而不是 0 或默认值");
|
||||
same(missing?.state, FACT_MISSING_VALUE, "缺的键状态也是「未报告」");
|
||||
same(memoryTidyRows(null).length, 7, "整份响应都没拿到时,7 行仍然都在(全为未报告)");
|
||||
ok(
|
||||
memoryTidyRows(null).every((r) => r.state === FACT_MISSING_VALUE),
|
||||
"没有响应时不留空白行,全部如实标「未报告」",
|
||||
);
|
||||
|
||||
// ── ④ 安静时段:窗口摘要 + 用途文案(新语义,不得说「夜间全静默」) ─────────
|
||||
same(quietHoursRows(DEFAULTS).map((r) => r.key), [...QUIET_HOURS_KEYS], "安静时段两行");
|
||||
same(quietHoursSummary(DEFAULTS.quietHours), "23:00-07:00(跨午夜)", "跨午夜窗口如实标注");
|
||||
same(
|
||||
quietHoursSummary({ startHour: 1, endHour: 5, disabled: false, label: "01:00-05:00" }),
|
||||
"01:00-05:00",
|
||||
"非跨午夜窗口不加多余后缀",
|
||||
);
|
||||
same(
|
||||
quietHoursSummary({ startHour: 23, endHour: 23, disabled: true, label: "已关闭" }),
|
||||
"已关闭(安静时段从不生效)",
|
||||
"窗口关闭必须说「从不生效」,不能画成 23:00-23:00 的假窗口",
|
||||
);
|
||||
same(
|
||||
quietHoursSummary({ startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00", fallbackToDefault: true }),
|
||||
"23:00-07:00(两端写 0 = 未填,运行时回落到默认窗口)",
|
||||
"0/0 被当成「未填」时必须说明回落,不能谎报成「已关闭」",
|
||||
);
|
||||
same(quietHoursSummary(null), FACT_MISSING_VALUE, "没有 quietHours 时如实报未报告");
|
||||
|
||||
const purpose = quietHoursPurpose();
|
||||
ok(has(purpose, "定时主动思考"), "用途文案必须点明它服务「定时主动思考 → 主动搭话」");
|
||||
ok(has(purpose, "不静默回复") || has(purpose, "不会因为到了安静时段就被静默"), "用途文案必须说清回复侧不被静默");
|
||||
ok(!has(purpose, "夜间全静默"), "不得沿用已废弃的旧语义「夜间全静默」");
|
||||
ok(!has(purpose, "群聊静默"), "群聊夜间静默已被取消,不得写成有");
|
||||
|
||||
// ── ⑤ factRows 对未知键不崩、原样透出键名 ───────────────────────────────────
|
||||
const unknown = factRows(DEFAULTS, ["NOT_A_REAL_KEY"]);
|
||||
same(unknown[0].label, "NOT_A_REAL_KEY", "没有元数据的键用键名兜底(不编中文标签)");
|
||||
same(unknown[0].value, FACT_MISSING_VALUE, "没有元数据的键如实「未报告」");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`configFacts: ${checks} checks passed`);
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AdminConfigFact, AdminConfigFacts, AdminConfigQuietHours } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* 配置只读事实卡的**纯逻辑**(契约 v0.68 §4.10(a))。这里只做形状 → 文案的映射,
|
||||
* 不发请求、不碰 DOM,因此可以用 plain-node 检查跑(见 configFacts.test.ts)。
|
||||
*
|
||||
* 三条不变量(与后端 admin_config_facts.go 同一口径):
|
||||
* 1. 值一律取后端给的**进程生效值**,本层不推算、不格式化数值(只补单位/说明);
|
||||
* 2. 「默认 / 已改」只由 `isDefault` 决定,**不**反推 `.env` 写过什么(面板读不到);
|
||||
* 3. 后端没给的键如实显示「未报告」,**绝不**编一个值出来。
|
||||
*/
|
||||
|
||||
/** 后端没给这条事实时的显示值(不是 0,也不是默认值)。 */
|
||||
export const FACT_MISSING_VALUE = "未报告";
|
||||
|
||||
/** 记忆整理的 7 个键(与后端 configFactSpecs 的前 7 条同序)。 */
|
||||
export const MEMORY_TIDY_KEYS: readonly string[] = [
|
||||
"MEMORY_TIDY_ENABLED",
|
||||
"MEMORY_TIDY_INTERVAL_SEC",
|
||||
"MEMORY_TIDY_QUIET_START_HOUR",
|
||||
"MEMORY_TIDY_QUIET_END_HOUR",
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD",
|
||||
"MEMORY_TIDY_SCAN_LIMIT",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT",
|
||||
];
|
||||
|
||||
/** 安静时段的两个键(后端事实表后两条)。 */
|
||||
export const QUIET_HOURS_KEYS: readonly string[] = ["QUIET_START_HOUR", "QUIET_END_HOUR"];
|
||||
|
||||
export interface FactRow {
|
||||
key: string;
|
||||
/** 中文说明(这个键管什么)。 */
|
||||
label: string;
|
||||
/** 显示值(生效值 + 单位)。 */
|
||||
value: string;
|
||||
/** `"默认"` / `"已改"` / `"未报告"`。 */
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface FactMeta {
|
||||
label: string;
|
||||
/** 值后缀(单位);空串表示不加。 */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const FACT_META: Record<string, FactMeta> = {
|
||||
MEMORY_TIDY_ENABLED: { label: "记忆整理总开关" },
|
||||
MEMORY_TIDY_INTERVAL_SEC: { label: "整理周期", unit: " 秒" },
|
||||
MEMORY_TIDY_QUIET_START_HOUR: { label: "整理低峰窗口起点", unit: " 时" },
|
||||
MEMORY_TIDY_QUIET_END_HOUR: { label: "整理低峰窗口终点", unit: " 时" },
|
||||
MEMORY_TIDY_RECALL_THRESHOLD: { label: "候选召回门(余弦)" },
|
||||
MEMORY_TIDY_SCAN_LIMIT: { label: "每轮扫描上限" },
|
||||
MEMORY_TIDY_CANDIDATE_LIMIT: { label: "每轮候选上限" },
|
||||
QUIET_START_HOUR: { label: "安静时段起点(本地钟点)", unit: " 时" },
|
||||
QUIET_END_HOUR: { label: "安静时段终点(本地钟点)", unit: " 时" },
|
||||
};
|
||||
|
||||
/** factRows 把后端事实列表按给定键序映射成显示行;缺的键如实标「未报告」。 */
|
||||
export function factRows(facts: AdminConfigFacts | null, keys: readonly string[]): FactRow[] {
|
||||
const byKey = new Map<string, AdminConfigFact>();
|
||||
for (const f of facts?.facts ?? []) byKey.set(f.key, f);
|
||||
return keys.map((key) => {
|
||||
const meta = FACT_META[key];
|
||||
const got = byKey.get(key);
|
||||
if (!got) {
|
||||
return {
|
||||
key,
|
||||
label: meta ? meta.label : key,
|
||||
value: FACT_MISSING_VALUE,
|
||||
state: FACT_MISSING_VALUE,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: meta ? meta.label : key,
|
||||
value: `${got.value}${meta?.unit ?? ""}`,
|
||||
state: got.isDefault ? "默认" : "已改",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 记忆整理事实行(7 条)。 */
|
||||
export function memoryTidyRows(facts: AdminConfigFacts | null): FactRow[] {
|
||||
return factRows(facts, MEMORY_TIDY_KEYS);
|
||||
}
|
||||
|
||||
/** 安静时段事实行(2 条)。 */
|
||||
export function quietHoursRows(facts: AdminConfigFacts | null): FactRow[] {
|
||||
return factRows(facts, QUIET_HOURS_KEYS);
|
||||
}
|
||||
|
||||
/** 安静时段窗口的一句话摘要(后端 label + 跨午夜 / 已关闭 / 「0/0 = 未填」的如实补充)。 */
|
||||
export function quietHoursSummary(qh: AdminConfigQuietHours | null | undefined): string {
|
||||
if (!qh) return FACT_MISSING_VALUE;
|
||||
if (qh.disabled) return "已关闭(安静时段从不生效)";
|
||||
const label = qh.label || `${qh.startHour}:00-${qh.endHour}:00`;
|
||||
if (qh.fallbackToDefault) {
|
||||
return `${label}(两端写 0 = 未填,运行时回落到默认窗口)`;
|
||||
}
|
||||
return qh.startHour > qh.endHour ? `${label}(跨午夜)` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安静时段的**用途文案**(主人裁决 10 的新语义)。
|
||||
* ⚠️ 必须说清「只服务定时主动思考 → 主动搭话,不静默回复」——
|
||||
* 写成「夜间全静默」是已废弃的旧实现语义,被这次改动明确否掉了。
|
||||
*/
|
||||
export function quietHoursPurpose(): string {
|
||||
return "安静时段只服务「定时主动思考 → 主动搭话」:她在这个窗口里默认不主动找你。回复侧不受它约束——你任何时候说话,她都照常回,不会因为到了安静时段就被静默。群聊同样没有夜间静默。";
|
||||
}
|
||||
Reference in new issue
Block a user