主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」 此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答 (批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。 四条不可走样: - 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器 工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定, 配置无权翻转; - 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点 (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前; - 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复; - 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom), 被静默的消息进不了批。 实现落点: - lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记; - coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数; - coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车; - coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch; - runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键); - gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 + writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批); - 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。 M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条 按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。 回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build + 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
344 lines
15 KiB
TypeScript
344 lines
15 KiB
TypeScript
/**
|
||
* 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。
|
||
*
|
||
* 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
|
||
* 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是
|
||
* 独立的一条消息,绝不拼成一条)。
|
||
*
|
||
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
|
||
* 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端
|
||
* 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开);
|
||
* 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp
|
||
* 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」;
|
||
* 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。
|
||
*
|
||
* 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture),
|
||
* `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。
|
||
*/
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { request } from "../../../lib/api/core";
|
||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||
import { Notice } from "../../components";
|
||
import { useAuditStore } from "../../audit";
|
||
|
||
/** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */
|
||
export interface CoalescePolicyView {
|
||
enabled: boolean;
|
||
windowMs: number;
|
||
maxWaitMs: number;
|
||
maxBatchMessages: number;
|
||
applyToStrangers: boolean;
|
||
strangerWindowMs: number;
|
||
}
|
||
|
||
/** 取值规则(后端 AdminCoalesceRules)。 */
|
||
export interface CoalesceRules {
|
||
windowMs: [number, number];
|
||
maxWaitMs: [number, number];
|
||
maxBatchMessages: [number, number];
|
||
clamp: string;
|
||
flushReasons: string[];
|
||
}
|
||
|
||
/** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */
|
||
export interface CoalesceView {
|
||
path: string;
|
||
present: boolean;
|
||
hot: boolean;
|
||
default: CoalescePolicyView;
|
||
patch: Record<string, unknown>;
|
||
conversations: Record<string, CoalescePolicyView>;
|
||
rules: CoalesceRules;
|
||
ownerPrivate: string;
|
||
}
|
||
|
||
/** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */
|
||
export interface CoalesceDraft {
|
||
enabled: string;
|
||
windowMs: string;
|
||
maxWaitMs: string;
|
||
maxBatchMessages: string;
|
||
applyToStrangers: string;
|
||
strangerWindowMs: string;
|
||
}
|
||
|
||
/** 只读事实的文案(渲染测试钉住它必须出现)。 */
|
||
export const OWNER_PRIVATE_NOTE =
|
||
"主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)";
|
||
|
||
/** 热生效文案。 */
|
||
export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)";
|
||
|
||
/** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */
|
||
export const COALESCE_FIELDS: ReadonlyArray<{
|
||
key: keyof CoalesceDraft;
|
||
label: string;
|
||
kind: "bool" | "int";
|
||
hint: string;
|
||
}> = [
|
||
{ key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" },
|
||
{ key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" },
|
||
{ key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" },
|
||
{ key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" },
|
||
{ key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" },
|
||
{ key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" },
|
||
];
|
||
|
||
/** draftFromView 把生效值铺成草稿。 */
|
||
export function draftFromView(view: CoalesceView): CoalesceDraft {
|
||
return {
|
||
enabled: String(view.default.enabled),
|
||
windowMs: String(view.default.windowMs),
|
||
maxWaitMs: String(view.default.maxWaitMs),
|
||
maxBatchMessages: String(view.default.maxBatchMessages),
|
||
applyToStrangers: String(view.default.applyToStrangers),
|
||
strangerWindowMs: String(view.default.strangerWindowMs),
|
||
};
|
||
}
|
||
|
||
/** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */
|
||
export function parseCoalescePatch(
|
||
view: CoalesceView,
|
||
draft: CoalesceDraft
|
||
): { patch: { default: Record<string, number | boolean> }; errors: Record<string, string>; changed: string[] } {
|
||
const errors: Record<string, string> = {};
|
||
const out: Record<string, number | boolean> = {};
|
||
const changed: string[] = [];
|
||
const intIn = (key: string, value: string, lo: number, hi: number): number | null => {
|
||
const trimmed = value.trim();
|
||
if (!/^\d+$/.test(trimmed)) {
|
||
errors[key] = "要填整数(毫秒/条)";
|
||
return null;
|
||
}
|
||
const n = Number(trimmed);
|
||
if (n < lo || n > hi) {
|
||
errors[key] = `取值范围 ${lo}–${hi}`;
|
||
return null;
|
||
}
|
||
return n;
|
||
};
|
||
const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]);
|
||
const maxWait = window === null
|
||
? null
|
||
: intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]);
|
||
const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]);
|
||
const strangerWindow = intIn(
|
||
"strangerWindowMs",
|
||
draft.strangerWindowMs,
|
||
view.rules.windowMs[0],
|
||
view.rules.windowMs[1]
|
||
);
|
||
const enabled = draft.enabled === "true";
|
||
const applyToStrangers = draft.applyToStrangers === "true";
|
||
const put = (key: string, next: number | boolean, current: number | boolean): void => {
|
||
if (next !== current) {
|
||
out[key] = next;
|
||
changed.push(key);
|
||
}
|
||
};
|
||
if (window !== null) put("windowMs", window, view.default.windowMs);
|
||
if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs);
|
||
if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages);
|
||
if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs);
|
||
put("enabled", enabled, view.default.enabled);
|
||
put("applyToStrangers", applyToStrangers, view.default.applyToStrangers);
|
||
return { patch: { default: out }, errors, changed };
|
||
}
|
||
|
||
/** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */
|
||
export function fetchCoalesce(): Promise<CoalesceView> {
|
||
return request<CoalesceView>("/admin/channels/coalesce");
|
||
}
|
||
|
||
/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */
|
||
export function saveCoalesce(patch: { default: Record<string, number | boolean> }): Promise<CoalesceView> {
|
||
return request<CoalesceView>("/admin/channels/coalesce", {
|
||
method: "PUT",
|
||
body: JSON.stringify(patch),
|
||
});
|
||
}
|
||
|
||
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
|
||
export interface CoalesceBodyProps {
|
||
view: CoalesceView;
|
||
draft: CoalesceDraft;
|
||
errors: Record<string, string>;
|
||
busy: boolean;
|
||
receipt: string | null;
|
||
savedView: CoalesceView | null;
|
||
onDraftChange: (key: keyof CoalesceDraft, value: string) => void;
|
||
onSave: () => void;
|
||
}
|
||
|
||
/** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
|
||
export function CoalesceCardBody({
|
||
view,
|
||
draft,
|
||
errors,
|
||
busy,
|
||
receipt,
|
||
savedView,
|
||
onDraftChange,
|
||
onSave,
|
||
}: CoalesceBodyProps): React.ReactNode {
|
||
const hasErrors = Object.keys(errors).length > 0;
|
||
const inputCls =
|
||
"w-36 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)]";
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Tag tone="accent">{COALESCE_HOT_LABEL}</Tag>
|
||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
|
||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||
{view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
|
||
</span>
|
||
</div>
|
||
<Notice>{OWNER_PRIVATE_NOTE}</Notice>
|
||
{COALESCE_FIELDS.map((field) => (
|
||
<div key={field.key} className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{field.label}</span>
|
||
{field.kind === "bool" ? (
|
||
<select
|
||
value={draft[field.key]}
|
||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||
onChange={(e) => onDraftChange(field.key, e.target.value)}
|
||
>
|
||
<option value="true">开</option>
|
||
<option value="false">关</option>
|
||
</select>
|
||
) : (
|
||
<input
|
||
value={draft[field.key]}
|
||
inputMode="numeric"
|
||
className={inputCls}
|
||
onChange={(e) => onDraftChange(field.key, e.target.value)}
|
||
/>
|
||
)}
|
||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{field.key}</span>
|
||
</div>
|
||
<div className="text-[11px] text-[var(--mu-text-lo)]">{field.hint}</div>
|
||
{errors[field.key] ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {errors[field.key]}</div> : null}
|
||
</div>
|
||
))}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
|
||
{busy ? "保存中…" : "保存窗口设置"}
|
||
</Button>
|
||
{hasErrors ? (
|
||
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
|
||
) : null}
|
||
</div>
|
||
{receipt ? <Notice>{receipt}</Notice> : null}
|
||
{savedView ? (
|
||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||
<span className="text-[var(--mu-text-mid)]">保存后回显(后端重新读盘的真值):</span>
|
||
<div className="font-mono text-[var(--mu-text-lo)]">
|
||
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "}
|
||
{savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
|
||
</div>
|
||
<div className="text-[var(--mu-text-mid)]">{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}</div>
|
||
</div>
|
||
) : null}
|
||
{Object.keys(view.conversations).length > 0 ? (
|
||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||
<span className="text-[var(--mu-text-mid)]">每会话覆盖(在配置文件里维护,键 = 平台|渠道):</span>
|
||
{Object.entries(view.conversations).map(([key, policy]) => (
|
||
<div key={key} className="font-mono text-[var(--mu-text-lo)]">
|
||
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||
规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "}
|
||
{view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。
|
||
发车原因:{view.rules.flushReasons.join(" / ")}。
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */
|
||
export function ChannelCoalesceCard(): React.ReactNode {
|
||
const [view, setView] = useState<CoalesceView | null>(null);
|
||
const [draft, setDraft] = useState<CoalesceDraft | null>(null);
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
const [busy, setBusy] = useState(false);
|
||
const [fail, setFail] = useState<string | null>(null);
|
||
const [receipt, setReceipt] = useState<string | null>(null);
|
||
const [savedView, setSavedView] = useState<CoalesceView | null>(null);
|
||
const log = useAuditStore((s) => s.log);
|
||
|
||
const reload = useCallback(() => {
|
||
setFail(null);
|
||
return fetchCoalesce()
|
||
.then((next) => {
|
||
setView(next);
|
||
setDraft(draftFromView(next));
|
||
})
|
||
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败"));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void reload();
|
||
}, [reload]);
|
||
|
||
const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => {
|
||
setDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||
setReceipt(null);
|
||
}, []);
|
||
|
||
const onSave = useCallback(() => {
|
||
if (!view || !draft) return;
|
||
const check = parseCoalescePatch(view, draft);
|
||
setErrors(check.errors);
|
||
if (Object.keys(check.errors).length > 0) return;
|
||
if (check.changed.length === 0) {
|
||
setReceipt("没有改动(一个键都没写)");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setFail(null);
|
||
saveCoalesce(check.patch)
|
||
.then((next) => {
|
||
setView(next);
|
||
setDraft(draftFromView(next));
|
||
setSavedView(next);
|
||
setErrors({});
|
||
setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`);
|
||
log("channels.coalesce", "ok");
|
||
})
|
||
.catch((e: unknown) => {
|
||
setFail(e instanceof Error ? e.message : "保存窗口配置失败");
|
||
log("channels.coalesce", "error");
|
||
})
|
||
.finally(() => setBusy(false));
|
||
}, [view, draft, log]);
|
||
|
||
return (
|
||
<Surface className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="text-sm font-semibold">群聊静默窗口合并</h3>
|
||
<Tag tone="neutral">GET / PUT /api/v1/admin/channels/coalesce</Tag>
|
||
</div>
|
||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||
{view === null || draft === null ? (
|
||
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
|
||
) : (
|
||
<CoalesceCardBody
|
||
view={view}
|
||
draft={draft}
|
||
errors={errors}
|
||
busy={busy}
|
||
receipt={receipt}
|
||
savedView={savedView}
|
||
onDraftChange={onDraftChange}
|
||
onSave={onSave}
|
||
/>
|
||
)}
|
||
</Surface>
|
||
);
|
||
}
|