chore(format): 撤销误入提交的整体重排,并关闭本仓库的格式化器
# 发生了什么 pi-lens 内置「安全格式化」:它会自动安装 biome 并对**编辑过的文件**跑 `biome format --write`。本机原先没有任何 biome 配置,于是 biome 用它自己的 默认值 —— tab 缩进 + 双引号 —— 把文件整体重写。 我在19a3161那次提交里用了 `git add -A`,把这批与功能无关的重排一起扫了进去: 约 7000 行改动散落在 20 个文件上,使那次提交无法审查,还掩盖了 server/internal/handler/permission.go 的一处删行(实为文件末尾空行,无代码丢失)。 # 为什么是「关掉」而不是「配置成我们的风格」 试过把缩进/引号/lineWidth 全部对齐本仓库习惯(biome.json + space/2/single/ lineWidth 120):`biome format --write` 仍然改动 17 个文件。原因是本仓库从未按 biome 的规则排版过 —— 注释按语义换行、数组与调用按可读性手工折行, 这些无法由格式化器还原。也就是说只要格式化器开着,每次编辑都会产生与内容无关的 大面积 diff,把真正的改动埋掉。 因此 biome.jsonc 里 formatter 与 linter 都关闭:本仓库的静态检查由 tsc / go vet / tree-sitter / ast-grep 与各自测试套件承担,不引入会改动无关行的 自动修复。 (pi-lens 这一版把 format 服务的 enabled 硬编码为 true,没有配置开关, 所以只能在仓库侧用 biome 配置让它不动文件;已验证 `biome format --write` 对这些文件零改动。) # 本提交内容 把19a3161里除「有意改动」外的 20 个文件还原到重排前的样子。19a3161中真正有意的改动是 deploy/install.sh 的扩展注册与 plugins/pi-mail-bridge/extension/index.ts 新文件,两者原样保留。 验证:Go 全量、三桥插件(320/362/409)、前端 196 全绿; `biome format --write` 对还原后的文件零改动。
This commit is contained in:
37
biome.jsonc
Normal file
37
biome.jsonc
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
// 本仓库**不使用** biome 的格式化器,只借它做诊断(且由 pi-lens 统一调度)。
|
||||||
|
//
|
||||||
|
// # 为什么显式关闭
|
||||||
|
//
|
||||||
|
// pi-lens 内置「安全格式化」,会对编辑过的文件跑
|
||||||
|
// `biome format --write`(沿 biome 标准向上查找配置)。本机没有任何
|
||||||
|
// biome 配置时,biome 用它自己的默认值:**tab 缩进 + 双引号**。
|
||||||
|
//
|
||||||
|
// 本仓库的手工排版与那套默认不兼容:注释按语义换行、数组与调用按可读性
|
||||||
|
// 手工折行,长注释常超过 100 列。实测把缩进/引号/lineWidth 全部对齐到本仓库
|
||||||
|
// 习惯之后,`biome format --write` 仍然改动 17 个文件 —— 也就是说只要格式化器
|
||||||
|
// 开着,每次编辑都会产生与内容无关的大面积 diff,把真正的改动埋掉。
|
||||||
|
//
|
||||||
|
// 曾经发生过:一次 `git add -A` 把约 7000 行纯重排扫进了功能提交
|
||||||
|
// (19a3161),使那次提交无法审查 —— 还掩盖了一处 Go 文件的删行。
|
||||||
|
//
|
||||||
|
// # 因此
|
||||||
|
//
|
||||||
|
// - formatter 关闭:保持本仓库既有排版,不再产生重排噪声
|
||||||
|
// - linter 关闭:`biome lint --write` 会做自动修复,同样会改到与本次改动
|
||||||
|
// 无关的行;本仓库的静态检查由 pi-lens 的其它 runner(tsc / tree-sitter /
|
||||||
|
// ast-grep / go vet)与各自的测试套件承担
|
||||||
|
//
|
||||||
|
// 新文件仍应遵守本仓库的既有风格(2 空格缩进、单引号)。
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,50 +1,35 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import Markdown from "react-markdown";
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useMailStore } from "../stores/mailStore";
|
import { useMailStore } from '../stores/mailStore';
|
||||||
import { useSessionStore } from "../stores/sessionStore";
|
import { useSessionStore } from '../stores/sessionStore';
|
||||||
import { useContactStore } from "../stores/contactStore";
|
import { useContactStore } from '../stores/contactStore';
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import {
|
import {
|
||||||
sessionReplyTarget,
|
sessionReplyTarget,
|
||||||
mailReplyTarget,
|
mailReplyTarget,
|
||||||
mailCounterpart,
|
mailCounterpart,
|
||||||
replyAllCC,
|
replyAllCC,
|
||||||
participantAddress,
|
participantAddress
|
||||||
} from "../lib/replyTarget";
|
} from '../lib/replyTarget';
|
||||||
import * as api from "../api/client";
|
import * as api from '../api/client';
|
||||||
import type { Mail } from "../types";
|
import type { Mail } from '../types';
|
||||||
import {
|
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
|
||||||
MailIcon,
|
import AddressInput from './AddressInput';
|
||||||
ShieldIcon,
|
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||||
PersonIcon,
|
import ThreadView from './ThreadView';
|
||||||
BotIcon,
|
import PermissionChip, { permissionModeHint } from './PermissionChip';
|
||||||
CheckIcon,
|
import BackButton from './BackButton';
|
||||||
CloseIcon,
|
|
||||||
ForwardIcon,
|
|
||||||
TreeIcon,
|
|
||||||
TagIcon,
|
|
||||||
GaugeIcon,
|
|
||||||
} from "./icons";
|
|
||||||
import AddressInput from "./AddressInput";
|
|
||||||
import {
|
|
||||||
AttachmentList,
|
|
||||||
AttachmentPicker,
|
|
||||||
type PendingAttachment,
|
|
||||||
} from "./Attachments";
|
|
||||||
import ThreadView from "./ThreadView";
|
|
||||||
import PermissionChip, { permissionModeHint } from "./PermissionChip";
|
|
||||||
import BackButton from "./BackButton";
|
|
||||||
|
|
||||||
export default function MailView() {
|
export default function MailView() {
|
||||||
const currentMail = useMailStore((s) => s.currentMail);
|
const currentMail = useMailStore(s => s.currentMail);
|
||||||
const markRead = useMailStore((s) => s.markRead);
|
const markRead = useMailStore(s => s.markRead);
|
||||||
const currentSession = useSessionStore((s) => s.currentSession);
|
const currentSession = useSessionStore(s => s.currentSession);
|
||||||
const currentSessionMails = useSessionStore((s) => s.currentSessionMails);
|
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
|
||||||
// 当前登录用户名:判定「哪封是我发的」的唯一基准。
|
// 当前登录用户名:判定「哪封是我发的」的唯一基准。
|
||||||
// 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf,
|
// 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf,
|
||||||
// 判据恒为假 —— 于是回复自己发的信时对端取成了自己。
|
// 判据恒为假 —— 于是回复自己发的信时对端取成了自己。
|
||||||
const me = useAuthStore((s) => s.user?.username || "");
|
const me = useAuthStore(s => s.user?.username || '');
|
||||||
// 转发面板作用于哪封邮件;null = 未打开
|
// 转发面板作用于哪封邮件;null = 未打开
|
||||||
const [forwarding, setForwarding] = useState<Mail | null>(null);
|
const [forwarding, setForwarding] = useState<Mail | null>(null);
|
||||||
// 正在看哪封邮件的对话树;null = 看正常的邮件视图
|
// 正在看哪封邮件的对话树;null = 看正常的邮件视图
|
||||||
@ -61,11 +46,7 @@ export default function MailView() {
|
|||||||
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
|
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
|
||||||
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
|
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
|
||||||
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
|
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
|
||||||
const sessionTarget = sessionReplyTarget(
|
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
|
||||||
currentSessionMails,
|
|
||||||
currentSession,
|
|
||||||
me,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
|
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
|
||||||
@ -74,7 +55,7 @@ export default function MailView() {
|
|||||||
<span className="text-sm font-semibold text-gray-900 font-mono">
|
<span className="text-sm font-semibold text-gray-900 font-mono">
|
||||||
{currentSession.session_alias
|
{currentSession.session_alias
|
||||||
? `.${currentSession.session_alias}`
|
? `.${currentSession.session_alias}`
|
||||||
: "(未命名会话)"}
|
: '(未命名会话)'}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge status={currentSession.status} />
|
<StatusBadge status={currentSession.status} />
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
@ -84,18 +65,12 @@ export default function MailView() {
|
|||||||
<PermissionEditor />
|
<PermissionEditor />
|
||||||
<BudgetEditor />
|
<BudgetEditor />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
|
||||||
{currentSession.subject}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<RenameProposalBar />
|
<RenameProposalBar />
|
||||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
|
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
|
||||||
{currentSessionMails.map((m) => (
|
{currentSessionMails.map(m => (
|
||||||
<ThreadCard
|
<ThreadCard key={m.mail_id} mail={m} onForward={() => setForwarding(m)} />
|
||||||
key={m.mail_id}
|
|
||||||
mail={m}
|
|
||||||
onForward={() => setForwarding(m)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
|
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
|
||||||
@ -114,9 +89,7 @@ export default function MailView() {
|
|||||||
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
|
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
|
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
|
||||||
<p className="text-sm mt-3 text-gray-500">
|
<p className="text-sm mt-3 text-gray-500">选择一封邮件查看,或点击左侧「新建」写邮件</p>
|
||||||
选择一封邮件查看,或点击左侧「新建」写邮件
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -139,7 +112,7 @@ export default function MailView() {
|
|||||||
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
<AttachmentList items={currentMail.attachments ?? []} />
|
<AttachmentList items={currentMail.attachments ?? []} />
|
||||||
{currentMail.mail_type === "permission_request" && (
|
{currentMail.mail_type === 'permission_request' && (
|
||||||
<PermissionPanel mail={currentMail} />
|
<PermissionPanel mail={currentMail} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -160,15 +133,15 @@ export default function MailView() {
|
|||||||
* 三档:plan(只读)/ workspace(目录内,越界问人)/ full(全权)。
|
* 三档:plan(只读)/ workspace(目录内,越界问人)/ full(全权)。
|
||||||
*/
|
*/
|
||||||
function PermissionEditor() {
|
function PermissionEditor() {
|
||||||
const session = useSessionStore((s) => s.currentSession);
|
const session = useSessionStore(s => s.currentSession);
|
||||||
const setPermissionMode = useSessionStore((s) => s.setPermissionMode);
|
const setPermissionMode = useSessionStore(s => s.setPermissionMode);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
const mode = session.permission_mode || "workspace";
|
const mode = session.permission_mode || 'workspace';
|
||||||
const enforcement = session.permission_enforcement || "advisory";
|
const enforcement = session.permission_enforcement || 'advisory';
|
||||||
|
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
return (
|
return (
|
||||||
@ -183,14 +156,14 @@ function PermissionEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
{ value: "plan", label: "只读", desc: "不许写/改/执行" },
|
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
|
||||||
{ value: "workspace", label: "目录内", desc: "越界问人" },
|
{ value: 'workspace', label: '目录内', desc: '越界问人' },
|
||||||
{ value: "full", label: "全权", desc: "自动放行" },
|
{ value: 'full', label: '全权', desc: '自动放行' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{modes.map((o) => (
|
{modes.map(o => (
|
||||||
<button
|
<button
|
||||||
key={o.value}
|
key={o.value}
|
||||||
type="button"
|
type="button"
|
||||||
@ -204,17 +177,14 @@ function PermissionEditor() {
|
|||||||
title={o.desc}
|
title={o.desc}
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors disabled:opacity-40 ${
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors disabled:opacity-40 ${
|
||||||
mode === o.value
|
mode === o.value
|
||||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||||
: "border-gray-200 text-gray-500 hover:bg-gray-50"
|
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button onClick={() => setEditing(false)} className="text-[10px] text-gray-400 hover:text-gray-700 ml-0.5">
|
||||||
onClick={() => setEditing(false)}
|
|
||||||
className="text-[10px] text-gray-400 hover:text-gray-700 ml-0.5"
|
|
||||||
>
|
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -229,10 +199,10 @@ function PermissionEditor() {
|
|||||||
* 而不是「这件事值得多少个来回」。
|
* 而不是「这件事值得多少个来回」。
|
||||||
*/
|
*/
|
||||||
function BudgetEditor() {
|
function BudgetEditor() {
|
||||||
const budget = useSessionStore((s) => s.budget);
|
const budget = useSessionStore(s => s.budget);
|
||||||
const setBudget = useSessionStore((s) => s.setBudget);
|
const setBudget = useSessionStore(s => s.setBudget);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
if (!budget) return null;
|
if (!budget) return null;
|
||||||
@ -240,7 +210,7 @@ function BudgetEditor() {
|
|||||||
const exhausted = !budget.unlimited && budget.remaining === 0;
|
const exhausted = !budget.unlimited && budget.remaining === 0;
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
setDraft(budget.unlimited ? "" : String(budget.max_rounds));
|
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -258,38 +228,36 @@ function BudgetEditor() {
|
|||||||
title="本任务的往返预算:Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
|
title="本任务的往返预算:Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
|
||||||
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
|
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
|
||||||
exhausted
|
exhausted
|
||||||
? "border-red-200 bg-red-50 text-red-700"
|
? 'border-red-200 bg-red-50 text-red-700'
|
||||||
: "border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600"
|
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GaugeIcon className="w-3 h-3" />
|
<GaugeIcon className="w-3 h-3" />
|
||||||
{budget.unlimited
|
{budget.unlimited
|
||||||
? "预算不限"
|
? '预算不限'
|
||||||
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? " · 已用尽" : ""}`}
|
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalid = draft.trim() !== "" && !/^\d+$/.test(draft.trim());
|
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] text-gray-500">往返预算</span>
|
<span className="text-[11px] text-gray-500">往返预算</span>
|
||||||
<input
|
<input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={e => setDraft(e.target.value)}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="不限"
|
placeholder="不限"
|
||||||
autoFocus
|
autoFocus
|
||||||
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||||
invalid ? "border-red-300" : "border-gray-300"
|
invalid ? 'border-red-300' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={busy || invalid}
|
disabled={busy || invalid}
|
||||||
onClick={() =>
|
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
|
||||||
commit({ max_rounds: draft.trim() === "" ? 0 : Number(draft.trim()) })
|
|
||||||
}
|
|
||||||
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
@ -320,17 +288,15 @@ function BudgetEditor() {
|
|||||||
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||||
*/
|
*/
|
||||||
function RenameProposalBar() {
|
function RenameProposalBar() {
|
||||||
const proposal = useSessionStore((s) => s.renameProposal);
|
const proposal = useSessionStore(s => s.renameProposal);
|
||||||
const current = useSessionStore((s) => s.currentSession);
|
const current = useSessionStore(s => s.currentSession);
|
||||||
const accept = useSessionStore((s) => s.acceptRename);
|
const accept = useSessionStore(s => s.acceptRename);
|
||||||
const dismiss = useSessionStore((s) => s.dismissRename);
|
const dismiss = useSessionStore(s => s.dismissRename);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
if (!proposal) return null;
|
if (!proposal) return null;
|
||||||
|
|
||||||
const from = current?.session_alias
|
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
|
||||||
? `.${current.session_alias}`
|
|
||||||
: "(未命名)";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
|
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
|
||||||
@ -338,14 +304,11 @@ function RenameProposalBar() {
|
|||||||
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs text-blue-900">
|
<p className="text-xs text-blue-900">
|
||||||
Agent 建议把会话别名从 <span className="font-mono">{from}</span>{" "}
|
Agent 建议把会话别名从 <span className="font-mono">{from}</span> 改为{' '}
|
||||||
改为{" "}
|
|
||||||
<span className="font-mono font-semibold">.{proposal.alias}</span>
|
<span className="font-mono font-semibold">.{proposal.alias}</span>
|
||||||
</p>
|
</p>
|
||||||
{proposal.reason && (
|
{proposal.reason && (
|
||||||
<p className="text-[11px] text-blue-700 mt-0.5">
|
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p>
|
||||||
{proposal.reason}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-blue-500 mt-0.5">
|
<p className="text-[10px] text-blue-500 mt-0.5">
|
||||||
改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。
|
改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。
|
||||||
@ -361,7 +324,7 @@ function RenameProposalBar() {
|
|||||||
}}
|
}}
|
||||||
className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
|
className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
|
||||||
>
|
>
|
||||||
{busy ? "改名中" : "接受"}
|
{busy ? '改名中' : '接受'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
@ -379,16 +342,16 @@ function RenameProposalBar() {
|
|||||||
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
|
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
|
||||||
*/
|
*/
|
||||||
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
||||||
const [to, setTo] = useState("");
|
const [to, setTo] = useState('');
|
||||||
const [cc, setCc] = useState("");
|
const [cc, setCc] = useState('');
|
||||||
const [ccOpen, setCcOpen] = useState(false);
|
const [ccOpen, setCcOpen] = useState(false);
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fetchInbox = useMailStore((s) => s.fetchInbox);
|
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||||
const fetchSent = useMailStore((s) => s.fetchSent);
|
const fetchSent = useMailStore(s => s.fetchSent);
|
||||||
const fetchSessions = useSessionStore((s) => s.fetchSessions);
|
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||||
const fetchContacts = useContactStore((s) => s.fetchContacts);
|
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!to.trim() || busy) return;
|
if (!to.trim() || busy) return;
|
||||||
@ -398,14 +361,9 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
await api.forwardMail(mail.mail_id, {
|
await api.forwardMail(mail.mail_id, {
|
||||||
to: to.trim(),
|
to: to.trim(),
|
||||||
cc: cc.trim(),
|
cc: cc.trim(),
|
||||||
comment: comment.trim(),
|
comment: comment.trim()
|
||||||
});
|
});
|
||||||
await Promise.all([
|
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||||
fetchInbox("all"),
|
|
||||||
fetchSent(),
|
|
||||||
fetchSessions(),
|
|
||||||
fetchContacts(),
|
|
||||||
]);
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@ -423,18 +381,14 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setCcOpen((o) => !o)}
|
onClick={() => setCcOpen(o => !o)}
|
||||||
className={`tap text-[10px] ${ccOpen ? "text-blue-600" : "text-gray-500 hover:text-blue-600"}`}
|
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
|
||||||
>
|
>
|
||||||
{ccOpen ? "收起抄送" : "抄送"}
|
{ccOpen ? '收起抄送' : '抄送'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddressInput
|
<AddressInput value={to} onChange={setTo} placeholder="新收件人:pi@root.new" />
|
||||||
value={to}
|
|
||||||
onChange={setTo}
|
|
||||||
placeholder="新收件人:pi@root.new"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{ccOpen && (
|
{ccOpen && (
|
||||||
<AddressInput
|
<AddressInput
|
||||||
@ -447,22 +401,15 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={e => setComment(e.target.value)}
|
||||||
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方)"
|
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方)"
|
||||||
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{error && (
|
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||||
<span className="text-xs text-red-600 min-w-0 break-words">
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
||||||
onClick={onClose}
|
|
||||||
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -470,7 +417,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
disabled={busy || !to.trim()}
|
disabled={busy || !to.trim()}
|
||||||
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{busy ? "转发中" : "转发"}
|
{busy ? '转发中' : '转发'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -481,14 +428,14 @@ function Header({
|
|||||||
mail,
|
mail,
|
||||||
onRead,
|
onRead,
|
||||||
onForward,
|
onForward,
|
||||||
onThread,
|
onThread
|
||||||
}: {
|
}: {
|
||||||
mail: Mail;
|
mail: Mail;
|
||||||
onRead: () => void;
|
onRead: () => void;
|
||||||
onForward: () => void;
|
onForward: () => void;
|
||||||
onThread: () => void;
|
onThread: () => void;
|
||||||
}) {
|
}) {
|
||||||
const time = new Date(mail.created_at).toLocaleString("zh-CN");
|
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||||
|
|
||||||
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
|
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
|
||||||
//
|
//
|
||||||
@ -501,44 +448,39 @@ function Header({
|
|||||||
//
|
//
|
||||||
// workspace 取 `session_workspace` 而不是 from/to_workspace:后者对 Agent
|
// workspace 取 `session_workspace` 而不是 from/to_workspace:后者对 Agent
|
||||||
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
|
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
|
||||||
const ws = mail.session_workspace || "";
|
const ws = mail.session_workspace || '';
|
||||||
const from = participantAddress(
|
const from = participantAddress(
|
||||||
mail.from_name,
|
mail.from_name,
|
||||||
mail.from_human,
|
mail.from_human,
|
||||||
mail.from_human ? "" : ws,
|
mail.from_human ? '' : ws,
|
||||||
mail.session_alias,
|
mail.session_alias
|
||||||
);
|
);
|
||||||
const to = participantAddress(
|
const to = participantAddress(
|
||||||
mail.to_name,
|
mail.to_name,
|
||||||
mail.to_human,
|
mail.to_human,
|
||||||
mail.to_human ? "" : ws,
|
mail.to_human ? '' : ws,
|
||||||
mail.session_alias,
|
mail.session_alias
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
|
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">
|
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
|
||||||
{mail.subject}
|
{mail.status === 'unread' && (
|
||||||
</h2>
|
|
||||||
{mail.status === "unread" && (
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
|
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
|
||||||
未读
|
未读
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{mail.mail_type === "permission_request" && (
|
{mail.mail_type === 'permission_request' && (
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
|
||||||
<ShieldIcon className="w-3 h-3" />
|
<ShieldIcon className="w-3 h-3" />
|
||||||
权限请求
|
权限请求
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{mail.status === "unread" && (
|
{mail.status === 'unread' && (
|
||||||
<button
|
<button onClick={onRead} className="tap text-xs text-blue-500 hover:underline">
|
||||||
onClick={onRead}
|
|
||||||
className="tap text-xs text-blue-500 hover:underline"
|
|
||||||
>
|
|
||||||
标记已读
|
标记已读
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -564,9 +506,7 @@ function Header({
|
|||||||
<Row label="收件">{to}</Row>
|
<Row label="收件">{to}</Row>
|
||||||
{mail.cc_list?.length > 0 && (
|
{mail.cc_list?.length > 0 && (
|
||||||
<Row label="抄送">
|
<Row label="抄送">
|
||||||
{mail.cc_list
|
{mail.cc_list.map(a => a.raw || `${a.name}@${a.path || ''}`).join('、')}
|
||||||
.map((a) => a.raw || `${a.name}@${a.path || ""}`)
|
|
||||||
.join("、")}
|
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
<Row label="时间">{time}</Row>
|
<Row label="时间">{time}</Row>
|
||||||
@ -575,13 +515,7 @@ function Header({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
|
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
|
||||||
@ -591,28 +525,22 @@ function Row({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThreadCard({
|
function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {
|
||||||
mail,
|
|
||||||
onForward,
|
|
||||||
}: {
|
|
||||||
mail: Mail;
|
|
||||||
onForward?: () => void;
|
|
||||||
}) {
|
|
||||||
// 「这封是我发的吗」而不是「发件人叫 human 吗」:多用户下登录名可能是
|
// 「这封是我发的吗」而不是「发件人叫 human 吗」:多用户下登录名可能是
|
||||||
// jianf,写死 'human' 会让自己发的信显示成机器人图标。
|
// jianf,写死 'human' 会让自己发的信显示成机器人图标。
|
||||||
const me = useAuthStore((s) => s.user?.username || "");
|
const me = useAuthStore(s => s.user?.username || '');
|
||||||
const isMine = !!me && mail.from_name === me;
|
const isMine = !!me && mail.from_name === me;
|
||||||
const isPermission = mail.mail_type === "permission_request";
|
const isPermission = mail.mail_type === 'permission_request';
|
||||||
const time = new Date(mail.created_at).toLocaleString("zh-CN");
|
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border p-4 ${
|
className={`rounded-lg border p-4 ${
|
||||||
isPermission
|
isPermission
|
||||||
? "border-orange-200 bg-orange-50"
|
? 'border-orange-200 bg-orange-50'
|
||||||
: isMine
|
: isMine
|
||||||
? "border-blue-200 bg-blue-50/60"
|
? 'border-blue-200 bg-blue-50/60'
|
||||||
: "border-gray-200 bg-white"
|
: 'border-gray-200 bg-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 mb-2 text-xs">
|
<div className="flex items-center gap-1.5 mb-2 text-xs">
|
||||||
@ -623,9 +551,7 @@ function ThreadCard({
|
|||||||
)}
|
)}
|
||||||
{/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方,
|
{/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方,
|
||||||
都渲染成 human 就分不清谁说的话 */}
|
都渲染成 human 就分不清谁说的话 */}
|
||||||
<span className="font-semibold text-gray-800 font-mono">
|
<span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span>
|
||||||
{mail.from_name}
|
|
||||||
</span>
|
|
||||||
{isPermission && (
|
{isPermission && (
|
||||||
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
|
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
|
||||||
权限请求
|
权限请求
|
||||||
@ -634,9 +560,7 @@ function ThreadCard({
|
|||||||
{mail.cc_list?.length > 0 && (
|
{mail.cc_list?.length > 0 && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] text-gray-400"
|
className="text-[10px] text-gray-400"
|
||||||
title={mail.cc_list
|
title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')}
|
||||||
.map((c) => c.raw || `${c.name}@${c.path || ""}`)
|
|
||||||
.join(", ")}
|
|
||||||
>
|
>
|
||||||
抄送 {mail.cc_list.length}
|
抄送 {mail.cc_list.length}
|
||||||
</span>
|
</span>
|
||||||
@ -677,21 +601,21 @@ function ThreadCard({
|
|||||||
* 那些铺垫与这个组件本身的行为无关。
|
* 那些铺垫与这个组件本身的行为无关。
|
||||||
*/
|
*/
|
||||||
export function PermissionPanel({ mail }: { mail: Mail }) {
|
export function PermissionPanel({ mail }: { mail: Mail }) {
|
||||||
const isQuestion = mail.permission_kind === "question";
|
const isQuestion = mail.permission_kind === 'question';
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [decided, setDecided] = useState(mail.permission_result || "");
|
const [decided, setDecided] = useState(mail.permission_result || '');
|
||||||
// 问题模式下已勾选的选项(多选时是多个)。
|
// 问题模式下已勾选的选项(多选时是多个)。
|
||||||
const [picked, setPicked] = useState<string[]>([]);
|
const [picked, setPicked] = useState<string[]>([]);
|
||||||
const fetchInbox = useMailStore((s) => s.fetchInbox);
|
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||||
const selectSession = useSessionStore((s) => s.selectSession);
|
const selectSession = useSessionStore(s => s.selectSession);
|
||||||
|
|
||||||
const submit = async (decision: string, noteText: string) => {
|
const submit = async (decision: string, noteText: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.decidePermission(mail.mail_id, decision, noteText || undefined);
|
await api.decidePermission(mail.mail_id, decision, noteText || undefined);
|
||||||
setDecided(decision || "(自由文本回答)");
|
setDecided(decision || '(自由文本回答)');
|
||||||
await fetchInbox("all");
|
await fetchInbox('all');
|
||||||
if (mail.session_id) selectSession(mail.session_id);
|
if (mail.session_id) selectSession(mail.session_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -714,11 +638,8 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
|||||||
const options = mail.permission_options ?? [];
|
const options = mail.permission_options ?? [];
|
||||||
const multi = mail.permission_multi_select === true;
|
const multi = mail.permission_multi_select === true;
|
||||||
const toggle = (opt: string) => {
|
const toggle = (opt: string) => {
|
||||||
setPicked((prev) => {
|
setPicked(prev => {
|
||||||
if (multi)
|
if (multi) return prev.includes(opt) ? prev.filter(p => p !== opt) : [...prev, opt];
|
||||||
return prev.includes(opt)
|
|
||||||
? prev.filter((p) => p !== opt)
|
|
||||||
: [...prev, opt];
|
|
||||||
// 单选:再点同一项则取消,否则替换
|
// 单选:再点同一项则取消,否则替换
|
||||||
return prev.includes(opt) ? [] : [opt];
|
return prev.includes(opt) ? [] : [opt];
|
||||||
});
|
});
|
||||||
@ -730,15 +651,13 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
|||||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||||
<div className="text-[11px] text-gray-500 mb-2">
|
<div className="text-[11px] text-gray-500 mb-2">
|
||||||
{options.length === 0
|
{options.length === 0
|
||||||
? "这题没有预设选项,请直接填写回答:"
|
? '这题没有预设选项,请直接填写回答:'
|
||||||
: multi
|
: multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'}
|
||||||
? "可多选,也可补充说明:"
|
|
||||||
: "请选择一项,也可补充说明:"}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{options.length > 0 && (
|
{options.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{options.map((opt) => {
|
{options.map(opt => {
|
||||||
const on = picked.includes(opt);
|
const on = picked.includes(opt);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -748,8 +667,8 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
|||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
|
||||||
on
|
on
|
||||||
? "bg-blue-700 text-white border-blue-700 hover:bg-blue-800"
|
? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800'
|
||||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{on && <CheckIcon className="w-3.5 h-3.5" />}
|
{on && <CheckIcon className="w-3.5 h-3.5" />}
|
||||||
@ -762,26 +681,22 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
|||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={e => setNote(e.target.value)}
|
||||||
rows={options.length === 0 ? 3 : 2}
|
rows={options.length === 0 ? 3 : 2}
|
||||||
placeholder={
|
placeholder={options.length === 0 ? '你的回答(必填)' : '补充说明(可选)'}
|
||||||
options.length === 0 ? "你的回答(必填)" : "补充说明(可选)"
|
|
||||||
}
|
|
||||||
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 resize-y focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 resize-y focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => submit(picked.join("\n"), note.trim())}
|
onClick={() => submit(picked.join('\n'), note.trim())}
|
||||||
disabled={busy || blank}
|
disabled={busy || blank}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-700 text-white hover:bg-blue-800 disabled:opacity-40"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-700 text-white hover:bg-blue-800 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
提交回答
|
提交回答
|
||||||
</button>
|
</button>
|
||||||
{blank && (
|
{blank && (
|
||||||
<span className="text-[11px] text-gray-400">
|
<span className="text-[11px] text-gray-400">请先选择或填写回答</span>
|
||||||
请先选择或填写回答
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -789,23 +704,21 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 审批型:批准 / 拒绝 ───
|
// ─── 审批型:批准 / 拒绝 ───
|
||||||
const options = mail.permission_options?.length
|
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
|
||||||
? mail.permission_options
|
|
||||||
: ["同意", "拒绝"];
|
|
||||||
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
|
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{options.map((opt) => (
|
{options.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt}
|
key={opt}
|
||||||
onClick={() => submit(opt, note)}
|
onClick={() => submit(opt, note)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||||
isApprove(opt)
|
isApprove(opt)
|
||||||
? "bg-green-700 text-white hover:bg-green-800"
|
? 'bg-green-700 text-white hover:bg-green-800'
|
||||||
: "bg-red-50 text-red-700 border border-red-200 hover:bg-red-100"
|
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isApprove(opt) ? (
|
{isApprove(opt) ? (
|
||||||
@ -819,7 +732,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={e => setNote(e.target.value)}
|
||||||
placeholder="备注(可选)"
|
placeholder="备注(可选)"
|
||||||
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||||
/>
|
/>
|
||||||
@ -835,30 +748,30 @@ function ReplyBar({
|
|||||||
* 会话视图的语义是「跟这个 Agent 的一次任务」,对端是会话的属性;
|
* 会话视图的语义是「跟这个 Agent 的一次任务」,对端是会话的属性;
|
||||||
* 单封邮件视图没有这层语境,才回落到按那封邮件推断。
|
* 单封邮件视图没有这层语境,才回落到按那封邮件推断。
|
||||||
*/
|
*/
|
||||||
overrideTarget,
|
overrideTarget
|
||||||
}: {
|
}: {
|
||||||
replyTo?: Mail;
|
replyTo?: Mail;
|
||||||
overrideTarget?: string;
|
overrideTarget?: string;
|
||||||
}) {
|
}) {
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState('');
|
||||||
const [cc, setCc] = useState("");
|
const [cc, setCc] = useState('');
|
||||||
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
|
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
|
||||||
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
|
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
|
||||||
const [ccOpen, setCcOpen] = useState(false);
|
const [ccOpen, setCcOpen] = useState(false);
|
||||||
const [budgetEditing, setBudgetEditing] = useState(false);
|
const [budgetEditing, setBudgetEditing] = useState(false);
|
||||||
const [budgetBusy, setBudgetBusy] = useState(false);
|
const [budgetBusy, setBudgetBusy] = useState(false);
|
||||||
const [maxRoundsDraft, setMaxRoundsDraft] = useState("");
|
const [maxRoundsDraft, setMaxRoundsDraft] = useState('');
|
||||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fetchInbox = useMailStore((s) => s.fetchInbox);
|
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||||
const fetchSent = useMailStore((s) => s.fetchSent);
|
const fetchSent = useMailStore(s => s.fetchSent);
|
||||||
const fetchSessions = useSessionStore((s) => s.fetchSessions);
|
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||||
const fetchContacts = useContactStore((s) => s.fetchContacts);
|
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||||
const selectSession = useSessionStore((s) => s.selectSession);
|
const selectSession = useSessionStore(s => s.selectSession);
|
||||||
const budget = useSessionStore((s) => s.budget);
|
const budget = useSessionStore(s => s.budget);
|
||||||
const setBudget = useSessionStore((s) => s.setBudget);
|
const setBudget = useSessionStore(s => s.setBudget);
|
||||||
const me = useAuthStore((s) => s.user?.username || "");
|
const me = useAuthStore(s => s.user?.username || '');
|
||||||
|
|
||||||
if (!replyTo) return null;
|
if (!replyTo) return null;
|
||||||
|
|
||||||
@ -875,18 +788,13 @@ function ReplyBar({
|
|||||||
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
|
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
|
||||||
reply_to: replyTo.mail_id,
|
reply_to: replyTo.mail_id,
|
||||||
cc: cc.trim(),
|
cc: cc.trim(),
|
||||||
attachment_ids: attachments.map((a) => a.id),
|
attachment_ids: attachments.map(a => a.id)
|
||||||
});
|
});
|
||||||
setBody("");
|
setBody('');
|
||||||
setCc("");
|
setCc('');
|
||||||
setCcOpen(false);
|
setCcOpen(false);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
await Promise.all([
|
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||||
fetchInbox("all"),
|
|
||||||
fetchSent(),
|
|
||||||
fetchSessions(),
|
|
||||||
fetchContacts(),
|
|
||||||
]);
|
|
||||||
if (replyTo.session_id) selectSession(replyTo.session_id);
|
if (replyTo.session_id) selectSession(replyTo.session_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
|
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
|
||||||
@ -907,16 +815,14 @@ function ReplyBar({
|
|||||||
const replyAll = () => {
|
const replyAll = () => {
|
||||||
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
|
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
|
||||||
// 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。
|
// 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。
|
||||||
setCc(replyAllCC(replyTo, me, peer.name).join(", "));
|
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
|
||||||
setCcOpen(true);
|
setCcOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
|
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate">
|
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
|
||||||
回复 {target}
|
|
||||||
</p>
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
||||||
<button
|
<button
|
||||||
@ -927,10 +833,10 @@ function ReplyBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCcOpen((o) => !o)}
|
onClick={() => setCcOpen(o => !o)}
|
||||||
className={`tap text-[10px] ${ccOpen ? "text-blue-600" : "text-gray-500 hover:text-blue-600"}`}
|
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
|
||||||
>
|
>
|
||||||
{ccOpen ? "收起抄送" : "抄送"}
|
{ccOpen ? '收起抄送' : '抄送'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{ccOpen && (
|
{ccOpen && (
|
||||||
@ -945,45 +851,35 @@ function ReplyBar({
|
|||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={e => setBody(e.target.value)}
|
||||||
placeholder="回复内容(Markdown)"
|
placeholder="回复内容(Markdown)"
|
||||||
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<AttachmentPicker
|
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
|
||||||
items={attachments}
|
|
||||||
onChange={setAttachments}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
{error && (
|
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||||
<span className="text-xs text-red-600 min-w-0 break-words">
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{budget && !budgetEditing && (
|
{budget && !budgetEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMaxRoundsDraft(
|
setMaxRoundsDraft(budget.unlimited ? '' : String(budget.max_rounds));
|
||||||
budget.unlimited ? "" : String(budget.max_rounds),
|
|
||||||
);
|
|
||||||
setBudgetEditing(true);
|
setBudgetEditing(true);
|
||||||
}}
|
}}
|
||||||
title="本任务往返预算:Agent 主动发信上限(自动转发与权限询问不占)"
|
title="本任务往返预算:Agent 主动发信上限(自动转发与权限询问不占)"
|
||||||
className={`tap inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded border ${
|
className={`tap inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded border ${
|
||||||
budget.unlimited
|
budget.unlimited
|
||||||
? "border-gray-200 text-gray-500 hover:border-blue-300"
|
? 'border-gray-200 text-gray-500 hover:border-blue-300'
|
||||||
: budget.remaining === 0
|
: budget.remaining === 0
|
||||||
? "border-red-200 text-red-600"
|
? 'border-red-200 text-red-600'
|
||||||
: "border-gray-200 text-gray-500 hover:border-blue-300"
|
: 'border-gray-200 text-gray-500 hover:border-blue-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GaugeIcon className="w-3 h-3" />
|
<GaugeIcon className="w-3 h-3" />
|
||||||
{budget.unlimited
|
{budget.unlimited
|
||||||
? "预算不限"
|
? '预算不限'
|
||||||
: `${budget.used_rounds}/${budget.max_rounds} 来回${budget.remaining === 0 ? " · 已用尽" : ""}`}
|
: `${budget.used_rounds}/${budget.max_rounds} 来回${budget.remaining === 0 ? ' · 已用尽' : ''}`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{budget && budgetEditing && (
|
{budget && budgetEditing && (
|
||||||
@ -991,26 +887,17 @@ function ReplyBar({
|
|||||||
<span className="text-[10px] text-gray-500">预算</span>
|
<span className="text-[10px] text-gray-500">预算</span>
|
||||||
<input
|
<input
|
||||||
value={maxRoundsDraft}
|
value={maxRoundsDraft}
|
||||||
onChange={(e) => setMaxRoundsDraft(e.target.value)}
|
onChange={e => setMaxRoundsDraft(e.target.value)}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="不限"
|
placeholder="不限"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-14 text-xs border border-gray-300 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
className="w-14 text-xs border border-gray-300 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={
|
disabled={budgetBusy || (maxRoundsDraft.trim() !== '' && !/^\d+$/.test(maxRoundsDraft.trim()))}
|
||||||
budgetBusy ||
|
|
||||||
(maxRoundsDraft.trim() !== "" &&
|
|
||||||
!/^\d+$/.test(maxRoundsDraft.trim()))
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setBudgetBusy(true);
|
setBudgetBusy(true);
|
||||||
await setBudget({
|
await setBudget({ max_rounds: maxRoundsDraft.trim() === '' ? 0 : Number(maxRoundsDraft.trim()) });
|
||||||
max_rounds:
|
|
||||||
maxRoundsDraft.trim() === ""
|
|
||||||
? 0
|
|
||||||
: Number(maxRoundsDraft.trim()),
|
|
||||||
});
|
|
||||||
setBudgetBusy(false);
|
setBudgetBusy(false);
|
||||||
setBudgetEditing(false);
|
setBudgetEditing(false);
|
||||||
}}
|
}}
|
||||||
@ -1028,7 +915,7 @@ function ReplyBar({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBody("");
|
setBody('');
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
|
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
|
||||||
@ -1040,7 +927,7 @@ function ReplyBar({
|
|||||||
disabled={busy || !body.trim()}
|
disabled={busy || !body.trim()}
|
||||||
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{busy ? "发送中" : "发送"}
|
{busy ? '发送中' : '发送'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1049,15 +936,11 @@ function ReplyBar({
|
|||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, { label: string; cls: string }> = {
|
const map: Record<string, { label: string; cls: string }> = {
|
||||||
active: { label: "进行中", cls: "bg-yellow-100 text-yellow-700" },
|
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
|
||||||
waiting: { label: "等待中", cls: "bg-blue-100 text-blue-700" },
|
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
|
||||||
completed: { label: "已完成", cls: "bg-green-100 text-green-700" },
|
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
|
||||||
archived: { label: "已归档", cls: "bg-gray-200 text-gray-600" },
|
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
|
||||||
};
|
};
|
||||||
const b = map[status] || map.active;
|
const b = map[status] || map.active;
|
||||||
return (
|
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>
|
|
||||||
{b.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,8 @@ export interface User {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
role: "admin" | "user";
|
role: 'admin' | 'user';
|
||||||
status: "active" | "disabled";
|
status: 'active' | 'disabled';
|
||||||
allowed_agents: string[];
|
allowed_agents: string[];
|
||||||
allowed_paths: string[];
|
allowed_paths: string[];
|
||||||
last_login?: string;
|
last_login?: string;
|
||||||
@ -46,7 +46,7 @@ export interface Session {
|
|||||||
* platform = Agent 平台自动同步来的,后续同步可以覆盖
|
* platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||||
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||||
*/
|
*/
|
||||||
alias_source?: "platform" | "manual";
|
alias_source?: 'platform' | 'manual';
|
||||||
/** 用户驳回过的改名提议 */
|
/** 用户驳回过的改名提议 */
|
||||||
rename_dismissed?: string;
|
rename_dismissed?: string;
|
||||||
/**
|
/**
|
||||||
@ -97,7 +97,7 @@ export interface Mail {
|
|||||||
cc_list: Address[];
|
cc_list: Address[];
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
mail_type: "normal" | "permission_request";
|
mail_type: 'normal' | 'permission_request';
|
||||||
permission_options: string[] | null;
|
permission_options: string[] | null;
|
||||||
permission_result: string | null;
|
permission_result: string | null;
|
||||||
/**
|
/**
|
||||||
@ -110,7 +110,7 @@ export interface Mail {
|
|||||||
permission_kind?: string;
|
permission_kind?: string;
|
||||||
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select)。 */
|
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select)。 */
|
||||||
permission_multi_select?: boolean;
|
permission_multi_select?: boolean;
|
||||||
status: "unread" | "read" | "archived";
|
status: 'unread' | 'read' | 'archived';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
hop_limit?: number;
|
hop_limit?: number;
|
||||||
session_alias?: string;
|
session_alias?: string;
|
||||||
@ -145,7 +145,7 @@ export interface Mail {
|
|||||||
* depth 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
* depth 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||||||
* 分块加载时根可能还没取到,所以不用「距根深度」。
|
* 分块加载时根可能还没取到,所以不用「距根深度」。
|
||||||
*/
|
*/
|
||||||
export interface ThreadNode extends Omit<Mail, "body"> {
|
export interface ThreadNode extends Omit<Mail, 'body'> {
|
||||||
/** 距**线索根**的层级:0 = 根,1 = 它的直接回复 */
|
/** 距**线索根**的层级:0 = 根,1 = 它的直接回复 */
|
||||||
depth: number;
|
depth: number;
|
||||||
attachment_count: number;
|
attachment_count: number;
|
||||||
@ -241,7 +241,7 @@ export interface HumanSession {
|
|||||||
permission_enforcement?: string;
|
permission_enforcement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SuggestKind = "name" | "path" | "session";
|
export type SuggestKind = 'name' | 'path' | 'session';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话候选项的来源。
|
* 会话候选项的来源。
|
||||||
@ -249,7 +249,7 @@ export type SuggestKind = "name" | "path" | "session";
|
|||||||
* platform 平台侧会话镜像(人直接在 opencode/DSH 界面上开的)
|
* platform 平台侧会话镜像(人直接在 opencode/DSH 界面上开的)
|
||||||
* new 新建会话的哨兵项
|
* new 新建会话的哨兵项
|
||||||
*/
|
*/
|
||||||
export type SessionCandidateSource = "mail" | "platform" | "new";
|
export type SessionCandidateSource = 'mail' | 'platform' | 'new';
|
||||||
|
|
||||||
export interface SessionCandidate {
|
export interface SessionCandidate {
|
||||||
/** 填进 session 位的值 */
|
/** 填进 session 位的值 */
|
||||||
@ -331,7 +331,7 @@ export interface CalendarEvent {
|
|||||||
recurrence: Recurrence;
|
recurrence: Recurrence;
|
||||||
/** 重复终止时间;越过它事件自动置为 cancelled */
|
/** 重复终止时间;越过它事件自动置为 cancelled */
|
||||||
recurrence_end?: string;
|
recurrence_end?: string;
|
||||||
status: "active" | "paused" | "cancelled";
|
status: 'active' | 'paused' | 'cancelled';
|
||||||
/** 上次触发的墙上时钟 */
|
/** 上次触发的墙上时钟 */
|
||||||
last_fired_at?: string;
|
last_fired_at?: string;
|
||||||
/**
|
/**
|
||||||
@ -353,15 +353,15 @@ export interface CalendarEvent {
|
|||||||
* 也没有 lunar_weekly(农历没有「周」这个单位)。
|
* 也没有 lunar_weekly(农历没有「周」这个单位)。
|
||||||
*/
|
*/
|
||||||
export type Recurrence =
|
export type Recurrence =
|
||||||
| "none"
|
| 'none'
|
||||||
| "daily"
|
| 'daily'
|
||||||
| "weekly"
|
| 'weekly'
|
||||||
| "monthly"
|
| 'monthly'
|
||||||
| "yearly"
|
| 'yearly'
|
||||||
| "lunar_monthly"
|
| 'lunar_monthly'
|
||||||
| "lunar_yearly";
|
| 'lunar_yearly';
|
||||||
|
|
||||||
export type DeliveryMode = "separate" | "together";
|
export type DeliveryMode = 'separate' | 'together';
|
||||||
|
|
||||||
/** 事件附件(随提醒邮件一起发出) */
|
/** 事件附件(随提醒邮件一起发出) */
|
||||||
export interface CalendarAttachment {
|
export interface CalendarAttachment {
|
||||||
@ -386,5 +386,5 @@ export interface CalendarEventInput {
|
|||||||
remind_before?: number;
|
remind_before?: number;
|
||||||
recurrence?: Recurrence;
|
recurrence?: Recurrence;
|
||||||
recurrence_end?: string | null;
|
recurrence_end?: string | null;
|
||||||
status?: "active" | "paused" | "cancelled";
|
status?: 'active' | 'paused' | 'cancelled';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from "react";
|
import React from 'react';
|
||||||
|
|
||||||
import { PermissionPanel } from "../../src/components/MailView";
|
import { PermissionPanel } from '../../src/components/MailView';
|
||||||
import * as api from "../../src/api/client";
|
import * as api from '../../src/api/client';
|
||||||
import { useMailStore } from "../../src/stores/mailStore";
|
import { useMailStore } from '../../src/stores/mailStore';
|
||||||
import { useSessionStore } from "../../src/stores/sessionStore";
|
import { useSessionStore } from '../../src/stores/sessionStore';
|
||||||
import type { Mail } from "../../src/types";
|
import type { Mail } from '../../src/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 权限决策面板。
|
* 权限决策面板。
|
||||||
@ -20,27 +20,27 @@ import type { Mail } from "../../src/types";
|
|||||||
|
|
||||||
function permMail(over: Partial<Mail> = {}): Mail {
|
function permMail(over: Partial<Mail> = {}): Mail {
|
||||||
return {
|
return {
|
||||||
mail_id: "m-1",
|
mail_id: 'm-1',
|
||||||
session_id: "s-1",
|
session_id: 's-1',
|
||||||
parent_mail_id: null,
|
parent_mail_id: null,
|
||||||
from_name: "dsh",
|
from_name: 'dsh',
|
||||||
from_workspace: "/home/program/agentmail",
|
from_workspace: '/home/program/agentmail',
|
||||||
to_name: "admin",
|
to_name: 'admin',
|
||||||
to_workspace: "",
|
to_workspace: '',
|
||||||
cc_list: [],
|
cc_list: [],
|
||||||
subject: "请求批准:删除 build/",
|
subject: '请求批准:删除 build/',
|
||||||
body: "将执行 rm -rf build/",
|
body: '将执行 rm -rf build/',
|
||||||
mail_type: "permission_request",
|
mail_type: 'permission_request',
|
||||||
permission_options: undefined,
|
permission_options: undefined,
|
||||||
permission_result: "",
|
permission_result: '',
|
||||||
status: "unread",
|
status: 'unread',
|
||||||
created_at: "2026-09-03T00:00:00Z",
|
created_at: '2026-09-03T00:00:00Z',
|
||||||
hop_limit: 5,
|
hop_limit: 5,
|
||||||
...over,
|
...over
|
||||||
} as Mail;
|
} as Mail;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("PermissionPanel 决策", () => {
|
describe('PermissionPanel 决策', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
// fetchInbox / selectSession 会打网络,替换成空实现
|
// fetchInbox / selectSession 会打网络,替换成空实现
|
||||||
@ -48,119 +48,100 @@ describe("PermissionPanel 决策", () => {
|
|||||||
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("没有 permission_options 时给默认的同意/拒绝两个选项", () => {
|
it('没有 permission_options 时给默认的同意/拒绝两个选项', () => {
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /同意/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /拒绝/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("有 permission_options 时用它,且顺序保持", () => {
|
it('有 permission_options 时用它,且顺序保持', () => {
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: permMail({
|
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
|
||||||
permission_options: ["只这一次", "总是允许", "拒绝"],
|
})
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const btns = screen
|
const btns = screen.getAllByRole('button').map(b => b.textContent?.trim());
|
||||||
.getAllByRole("button")
|
|
||||||
.map((b) => b.textContent?.trim());
|
|
||||||
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
|
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
|
||||||
expect(btns).toEqual(["只这一次", "总是允许", "拒绝"]);
|
expect(btns).toEqual(['只这一次', '总是允许', '拒绝']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("点选项时把【选项原文】发给服务端", async () => {
|
it('点选项时把【选项原文】发给服务端', async () => {
|
||||||
const spy = vi
|
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
.spyOn(api, "decidePermission")
|
|
||||||
.mockResolvedValue({ status: "decided" } as any);
|
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: permMail({ permission_options: ["只这一次", "拒绝"] }),
|
mail: permMail({ permission_options: ['只这一次', '拒绝'] })
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /只这一次/ }));
|
await userEvent.click(screen.getByRole('button', { name: /只这一次/ }));
|
||||||
|
|
||||||
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
|
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
|
||||||
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
|
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(spy).toHaveBeenCalledWith("m-1", "只这一次", undefined),
|
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("填了备注时一起发出去", async () => {
|
it('填了备注时一起发出去', async () => {
|
||||||
const spy = vi
|
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
.spyOn(api, "decidePermission")
|
|
||||||
.mockResolvedValue({ status: "decided" } as any);
|
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
|
|
||||||
await userEvent.type(
|
await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build,别动 dist');
|
||||||
screen.getByPlaceholderText("备注(可选)"),
|
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||||
"只删 build,别动 dist",
|
|
||||||
);
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(spy).toHaveBeenCalledWith("m-1", "同意", "只删 build,别动 dist"),
|
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build,别动 dist')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("备注为空时传 undefined 而不是空字符串", async () => {
|
it('备注为空时传 undefined 而不是空字符串', async () => {
|
||||||
const spy = vi
|
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
.spyOn(api, "decidePermission")
|
|
||||||
.mockResolvedValue({ status: "decided" } as any);
|
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||||
|
|
||||||
// 空串会在决策邮件里留一行空的「备注:」
|
// 空串会在决策邮件里留一行空的「备注:」
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
|
||||||
expect(spy).toHaveBeenCalledWith("m-1", "同意", undefined),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("决策后变成「已处理」,不再显示按钮", async () => {
|
it('决策后变成「已处理」,不再显示按钮', async () => {
|
||||||
vi.spyOn(api, "decidePermission").mockResolvedValue({
|
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
status: "decided",
|
|
||||||
} as any);
|
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||||
expect(screen.getByText("已处理:")).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
||||||
expect(screen.queryByRole("button")).toBeNull();
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("已经有 permission_result 的邮件直接显示结论", () => {
|
it('已经有 permission_result 的邮件直接显示结论', () => {
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: permMail({ permission_result: "拒绝" }),
|
mail: permMail({ permission_result: '拒绝' })
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("拒绝")).toBeInTheDocument();
|
expect(screen.getByText('拒绝')).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button")).toBeNull();
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("提交中禁用所有按钮,避免重复决策", async () => {
|
it('提交中禁用所有按钮,避免重复决策', async () => {
|
||||||
let release: (v: any) => void = () => {};
|
let release: (v: any) => void = () => {};
|
||||||
vi.spyOn(api, "decidePermission").mockReturnValue(
|
vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||||||
new Promise((res) => {
|
new Promise(res => {
|
||||||
release = res;
|
release = res;
|
||||||
}) as any,
|
}) as any
|
||||||
);
|
);
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||||
|
|
||||||
// 一次危险操作被批准两次,Agent 那边可能真的执行两遍
|
// 一次危险操作被批准两次,Agent 那边可能真的执行两遍
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
for (const b of screen.getAllByRole("button")) {
|
for (const b of screen.getAllByRole('button')) {
|
||||||
expect(b).toBeDisabled();
|
expect(b).toBeDisabled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -168,64 +149,56 @@ describe("PermissionPanel 决策", () => {
|
|||||||
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
|
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
|
||||||
// 否则组件在测试结束后才 setState,React 会报 act 警告
|
// 否则组件在测试结束后才 setState,React 会报 act 警告
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
release({ status: "decided" });
|
release({ status: 'decided' });
|
||||||
});
|
});
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||||
expect(screen.getByText("已处理:")).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("提交失败时恢复可点,不假装已决策", async () => {
|
it('提交失败时恢复可点,不假装已决策', async () => {
|
||||||
vi.spyOn(api, "decidePermission").mockRejectedValue(new Error("500"));
|
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||||
|
|
||||||
// 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等
|
// 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
|
||||||
expect(screen.getByRole("button", { name: /同意/ })).toBeEnabled(),
|
expect(screen.queryByText('已处理:')).toBeNull();
|
||||||
);
|
|
||||||
expect(screen.queryByText("已处理:")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("决策成功后刷新收件箱并选中该会话", async () => {
|
it('决策成功后刷新收件箱并选中该会话', async () => {
|
||||||
vi.spyOn(api, "decidePermission").mockResolvedValue({
|
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
status: "decided",
|
|
||||||
} as any);
|
|
||||||
const fetchInbox = vi.fn(async () => {});
|
const fetchInbox = vi.fn(async () => {});
|
||||||
const selectSession = vi.fn(async () => {});
|
const selectSession = vi.fn(async () => {});
|
||||||
useMailStore.setState({ fetchInbox } as any);
|
useMailStore.setState({ fetchInbox } as any);
|
||||||
useSessionStore.setState({ selectSession } as any);
|
useSessionStore.setState({ selectSession } as any);
|
||||||
|
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||||
|
|
||||||
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchInbox).toHaveBeenCalledWith("all");
|
expect(fetchInbox).toHaveBeenCalledWith('all');
|
||||||
expect(selectSession).toHaveBeenCalledWith("s-1");
|
expect(selectSession).toHaveBeenCalledWith('s-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("同意类选项用绿色,其余用红色", () => {
|
it('同意类选项用绿色,其余用红色', () => {
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: permMail({
|
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
|
||||||
permission_options: ["允许", "approve", "拒绝", "算了"],
|
})
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const cls = (name: string) =>
|
const cls = (name: string) =>
|
||||||
screen.getByRole("button", { name: new RegExp(name) }).className;
|
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||||
|
|
||||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||||
expect(cls("允许")).toContain("bg-green-700");
|
expect(cls('允许')).toContain('bg-green-700');
|
||||||
expect(cls("approve")).toContain("bg-green-700");
|
expect(cls('approve')).toContain('bg-green-700');
|
||||||
expect(cls("拒绝")).toContain("text-red-700");
|
expect(cls('拒绝')).toContain('text-red-700');
|
||||||
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
||||||
expect(cls("算了")).toContain("text-red-700");
|
expect(cls('算了')).toContain('text-red-700');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -237,7 +210,7 @@ describe("PermissionPanel 决策", () => {
|
|||||||
* - 不能把问题渲染成同意/拒绝(那会让人点出一个毫无意义的答案)
|
* - 不能把问题渲染成同意/拒绝(那会让人点出一个毫无意义的答案)
|
||||||
* - 空回答不能提交(模型会拿到一个什么都没说的结果继续跑)
|
* - 空回答不能提交(模型会拿到一个什么都没说的结果继续跑)
|
||||||
*/
|
*/
|
||||||
describe("PermissionPanel 回答问题", () => {
|
describe('PermissionPanel 回答问题', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
||||||
@ -245,134 +218,109 @@ describe("PermissionPanel 回答问题", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const questionMail = (over: Partial<Mail> = {}): Mail =>
|
const questionMail = (over: Partial<Mail> = {}): Mail =>
|
||||||
permMail({ permission_kind: "question", ...over });
|
permMail({ permission_kind: 'question', ...over });
|
||||||
|
|
||||||
it("问题不带选项时:不渲染同意/拒绝,只给自由文本", () => {
|
it('问题不带选项时:不渲染同意/拒绝,只给自由文本', () => {
|
||||||
render(
|
render(React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }) }));
|
||||||
React.createElement(PermissionPanel, {
|
|
||||||
mail: questionMail({ permission_options: [] }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: /同意/ })).toBeNull();
|
expect(screen.queryByRole('button', { name: /同意/ })).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: /拒绝/ })).toBeNull();
|
expect(screen.queryByRole('button', { name: /拒绝/ })).toBeNull();
|
||||||
expect(screen.getByPlaceholderText("你的回答(必填)")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('你的回答(必填)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("问题带选项时:渲染选项按钮(而不是同意/拒绝)", () => {
|
it('问题带选项时:渲染选项按钮(而不是同意/拒绝)', () => {
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: questionMail({ permission_options: ["方案 A", "方案 B"] }),
|
mail: questionMail({ permission_options: ['方案 A', '方案 B'] })
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /方案 A/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /方案 A/ })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /方案 B/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /方案 B/ })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /^同意$/ })).toBeNull();
|
expect(screen.queryByRole('button', { name: /^同意$/ })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("单选:再点已选项会取消,不会同时选中两个", async () => {
|
it('单选:再点已选项会取消,不会同时选中两个', async () => {
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: questionMail({ permission_options: ["A", "B"] }),
|
mail: questionMail({ permission_options: ['A', 'B'] })
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
const a = screen.getByRole("button", { name: /^A$/ });
|
const a = screen.getByRole('button', { name: /^A$/ });
|
||||||
const b = screen.getByRole("button", { name: /^B$/ });
|
const b = screen.getByRole('button', { name: /^B$/ });
|
||||||
|
|
||||||
await userEvent.click(a);
|
await userEvent.click(a);
|
||||||
expect(a).toHaveAttribute("aria-pressed", "true");
|
expect(a).toHaveAttribute('aria-pressed', 'true');
|
||||||
await userEvent.click(b);
|
await userEvent.click(b);
|
||||||
expect(b).toHaveAttribute("aria-pressed", "true");
|
expect(b).toHaveAttribute('aria-pressed', 'true');
|
||||||
expect(a).toHaveAttribute("aria-pressed", "false");
|
expect(a).toHaveAttribute('aria-pressed', 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("多选:可同时选中多项,提交时用换行拼接", async () => {
|
it('多选:可同时选中多项,提交时用换行拼接', async () => {
|
||||||
const spy = vi
|
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
.spyOn(api, "decidePermission")
|
|
||||||
.mockResolvedValue({ status: "decided" } as any);
|
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: questionMail({
|
mail: questionMail({ permission_options: ['A', 'B'], permission_multi_select: true })
|
||||||
permission_options: ["A", "B"],
|
})
|
||||||
permission_multi_select: true,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /^A$/ }));
|
await userEvent.click(screen.getByRole('button', { name: /^A$/ }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: /^B$/ }));
|
await userEvent.click(screen.getByRole('button', { name: /^B$/ }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: /提交回答/ }));
|
await userEvent.click(screen.getByRole('button', { name: /提交回答/ }));
|
||||||
|
|
||||||
// 服务端按换行拆分多选答案,不能拼接成 "AB" 或数组字符串
|
// 服务端按换行拆分多选答案,不能拼接成 "AB" 或数组字符串
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', 'A\nB', undefined));
|
||||||
expect(spy).toHaveBeenCalledWith("m-1", "A\nB", undefined),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("空回答禁止提交(模型不能拿到一个什么都没说的结果)", async () => {
|
it('空回答禁止提交(模型不能拿到一个什么都没说的结果)', async () => {
|
||||||
const spy = vi
|
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
.spyOn(api, "decidePermission")
|
render(React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }) }));
|
||||||
.mockResolvedValue({ status: "decided" } as any);
|
|
||||||
render(
|
|
||||||
React.createElement(PermissionPanel, {
|
|
||||||
mail: questionMail({ permission_options: [] }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const submit = screen.getByRole("button", { name: /提交回答/ });
|
const submit = screen.getByRole('button', { name: /提交回答/ });
|
||||||
expect(submit).toBeDisabled();
|
expect(submit).toBeDisabled();
|
||||||
expect(screen.getByText("请先选择或填写回答")).toBeInTheDocument();
|
expect(screen.getByText('请先选择或填写回答')).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.type(
|
await userEvent.type(screen.getByPlaceholderText('你的回答(必填)'), '配置在 /etc/foo.conf');
|
||||||
screen.getByPlaceholderText("你的回答(必填)"),
|
|
||||||
"配置在 /etc/foo.conf",
|
|
||||||
);
|
|
||||||
expect(submit).toBeEnabled();
|
expect(submit).toBeEnabled();
|
||||||
await userEvent.click(submit);
|
await userEvent.click(submit);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(spy).toHaveBeenCalledWith("m-1", "", "配置在 /etc/foo.conf"),
|
expect(spy).toHaveBeenCalledWith('m-1', '', '配置在 /etc/foo.conf')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("选了选项又写了备注:两者都发出去", async () => {
|
it('选了选项又写了备注:两者都发出去', async () => {
|
||||||
const spy = vi
|
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||||
.spyOn(api, "decidePermission")
|
|
||||||
.mockResolvedValue({ status: "decided" } as any);
|
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: questionMail({ permission_options: ["方案 A"] }),
|
mail: questionMail({ permission_options: ['方案 A'] })
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /方案 A/ }));
|
await userEvent.click(screen.getByRole('button', { name: /方案 A/ }));
|
||||||
await userEvent.type(
|
await userEvent.type(screen.getByPlaceholderText('补充说明(可选)'), '但要先备份');
|
||||||
screen.getByPlaceholderText("补充说明(可选)"),
|
await userEvent.click(screen.getByRole('button', { name: /提交回答/ }));
|
||||||
"但要先备份",
|
|
||||||
);
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /提交回答/ }));
|
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(spy).toHaveBeenCalledWith("m-1", "方案 A", "但要先备份"),
|
expect(spy).toHaveBeenCalledWith('m-1', '方案 A', '但要先备份')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("问题已回答过:显示结论,不再显示任何输入控件", () => {
|
it('问题已回答过:显示结论,不再显示任何输入控件', () => {
|
||||||
render(
|
render(
|
||||||
React.createElement(PermissionPanel, {
|
React.createElement(PermissionPanel, {
|
||||||
mail: questionMail({ permission_result: "方案 A" }),
|
mail: questionMail({ permission_result: '方案 A' })
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/已处理/)).toBeInTheDocument();
|
expect(screen.getByText(/已处理/)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button")).toBeNull();
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("审批型(无 permission_kind)仍然走同意/拒绝路径", () => {
|
it('审批型(无 permission_kind)仍然走同意/拒绝路径', () => {
|
||||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||||
|
|
||||||
// 回归防护:question 分支不能把普通审批也带走
|
// 回归防护:question 分支不能把普通审批也带走
|
||||||
expect(screen.getByRole("button", { name: /同意/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /提交回答/ })).toBeNull();
|
expect(screen.queryByRole('button', { name: /提交回答/ })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
10
plugins/dsh-mail-bridge/lib/mail-session-id.d.ts
vendored
10
plugins/dsh-mail-bridge/lib/mail-session-id.d.ts
vendored
@ -1,9 +1,3 @@
|
|||||||
export declare function dshSessionIdForMail(mailSessionID: any): string;
|
export declare function dshSessionIdForMail(mailSessionID: any): string;
|
||||||
export declare function matchesMailSession(
|
export declare function matchesMailSession(dshSessionId: any, mailSessionID: any): boolean;
|
||||||
dshSessionId: any,
|
export declare function pickMailSession(dshSessionIds: any[] | undefined, mailSessionID: any): string | undefined;
|
||||||
mailSessionID: any,
|
|
||||||
): boolean;
|
|
||||||
export declare function pickMailSession(
|
|
||||||
dshSessionIds: any[] | undefined,
|
|
||||||
mailSessionID: any,
|
|
||||||
): string | undefined;
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
/** 首次尝试使用的 DSH 会话 id。 */
|
/** 首次尝试使用的 DSH 会话 id。 */
|
||||||
export function dshSessionIdForMail(mailSessionID) {
|
export function dshSessionIdForMail(mailSessionID) {
|
||||||
return `mail-${String(mailSessionID ?? "")}`;
|
return `mail-${String(mailSessionID ?? '')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,11 +35,11 @@ export function dshSessionIdForMail(mailSessionID) {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function matchesMailSession(dshSessionId, mailSessionID) {
|
export function matchesMailSession(dshSessionId, mailSessionID) {
|
||||||
const id = String(dshSessionId ?? "");
|
const id = String(dshSessionId ?? '');
|
||||||
const base = dshSessionIdForMail(mailSessionID);
|
const base = dshSessionIdForMail(mailSessionID);
|
||||||
if (id === base) return true;
|
if (id === base) return true;
|
||||||
// 模型降级重试:mail-<id>-r1 / -r2 / …
|
// 模型降级重试:mail-<id>-r1 / -r2 / …
|
||||||
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : "";
|
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : '';
|
||||||
return suffix.length > 0 && /^\d+$/.test(suffix);
|
return suffix.length > 0 && /^\d+$/.test(suffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
plugins/dsh-mail-bridge/lib/user-question.d.ts
vendored
12
plugins/dsh-mail-bridge/lib/user-question.d.ts
vendored
@ -7,15 +7,7 @@ export declare function flattenQuestions(questions: any[]): {
|
|||||||
context: string;
|
context: string;
|
||||||
multiSelect: boolean;
|
multiSelect: boolean;
|
||||||
};
|
};
|
||||||
export declare function answersFromDecision(
|
export declare function answersFromDecision(questions: any[], decision: string, note?: string): {
|
||||||
questions: any[],
|
|
||||||
decision: string,
|
|
||||||
note?: string,
|
|
||||||
): {
|
|
||||||
answers: Array<{ id: string; selected: string[]; custom?: string }>;
|
answers: Array<{ id: string; selected: string[]; custom?: string }>;
|
||||||
};
|
};
|
||||||
export declare function isBlankAnswer(
|
export declare function isBlankAnswer(questions: any[], decision: string, note?: string): boolean;
|
||||||
questions: any[],
|
|
||||||
decision: string,
|
|
||||||
note?: string,
|
|
||||||
): boolean;
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -6,69 +6,59 @@
|
|||||||
* 运行时仍按旧档位执行 —— 人以为自己收紧了权限。
|
* 运行时仍按旧档位执行 —— 人以为自己收紧了权限。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from 'node:test';
|
||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
dshSessionIdForMail,
|
dshSessionIdForMail,
|
||||||
matchesMailSession,
|
matchesMailSession,
|
||||||
pickMailSession,
|
pickMailSession,
|
||||||
} from "../lib/mail-session-id.js";
|
} from '../lib/mail-session-id.js';
|
||||||
|
|
||||||
test("首次尝试的 id 是 mail-<邮件会话 id>", () => {
|
test('首次尝试的 id 是 mail-<邮件会话 id>', () => {
|
||||||
assert.equal(dshSessionIdForMail("abc-123"), "mail-abc-123");
|
assert.equal(dshSessionIdForMail('abc-123'), 'mail-abc-123');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("匹配首次尝试的 id", () => {
|
test('匹配首次尝试的 id', () => {
|
||||||
assert.equal(matchesMailSession("mail-abc", "abc"), true);
|
assert.equal(matchesMailSession('mail-abc', 'abc'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("匹配模型降级重试的 -r<i>", () => {
|
test('匹配模型降级重试的 -r<i>', () => {
|
||||||
assert.equal(matchesMailSession("mail-abc-r1", "abc"), true);
|
assert.equal(matchesMailSession('mail-abc-r1', 'abc'), true);
|
||||||
assert.equal(matchesMailSession("mail-abc-r12", "abc"), true);
|
assert.equal(matchesMailSession('mail-abc-r12', 'abc'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("不匹配别的会话(前缀相同也不行)", () => {
|
test('不匹配别的会话(前缀相同也不行)', () => {
|
||||||
assert.equal(
|
assert.equal(matchesMailSession('mail-abcdef', 'abc'), false, 'mail-abc 的前缀不能吃掉 mail-abcdef');
|
||||||
matchesMailSession("mail-abcdef", "abc"),
|
assert.equal(matchesMailSession('mail-abd', 'abc'), false);
|
||||||
false,
|
assert.equal(matchesMailSession('other-abc', 'abc'), false);
|
||||||
"mail-abc 的前缀不能吃掉 mail-abcdef",
|
assert.equal(matchesMailSession('', 'abc'), false);
|
||||||
);
|
|
||||||
assert.equal(matchesMailSession("mail-abd", "abc"), false);
|
|
||||||
assert.equal(matchesMailSession("other-abc", "abc"), false);
|
|
||||||
assert.equal(matchesMailSession("", "abc"), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("不匹配非数字后缀(避免误吞其它会话)", () => {
|
test('不匹配非数字后缀(避免误吞其它会话)', () => {
|
||||||
assert.equal(matchesMailSession("mail-abc-rx", "abc"), false);
|
assert.equal(matchesMailSession('mail-abc-rx', 'abc'), false);
|
||||||
assert.equal(matchesMailSession("mail-abc-r", "abc"), false);
|
assert.equal(matchesMailSession('mail-abc-r', 'abc'), false);
|
||||||
assert.equal(matchesMailSession("mail-abc-retry", "abc"), false);
|
assert.equal(matchesMailSession('mail-abc-retry', 'abc'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pickMailSession 优先首次尝试,而不是数组顺序", () => {
|
test('pickMailSession 优先首次尝试,而不是数组顺序', () => {
|
||||||
// 数组顺序可能来自 ctx.agents.list(),与尝试顺序无关
|
// 数组顺序可能来自 ctx.agents.list(),与尝试顺序无关
|
||||||
assert.equal(
|
assert.equal(pickMailSession(['mail-abc-r2', 'mail-abc-r1', 'mail-abc'], 'abc'), 'mail-abc');
|
||||||
pickMailSession(["mail-abc-r2", "mail-abc-r1", "mail-abc"], "abc"),
|
|
||||||
"mail-abc",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pickMailSession 没有首次尝试时取序号最小的重试", () => {
|
test('pickMailSession 没有首次尝试时取序号最小的重试', () => {
|
||||||
assert.equal(
|
assert.equal(pickMailSession(['mail-abc-r3', 'mail-abc-r1', 'mail-abc-r2'], 'abc'), 'mail-abc-r1');
|
||||||
pickMailSession(["mail-abc-r3", "mail-abc-r1", "mail-abc-r2"], "abc"),
|
|
||||||
"mail-abc-r1",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pickMailSession 找不到时返回 undefined(调用方据此跳过)", () => {
|
test('pickMailSession 找不到时返回 undefined(调用方据此跳过)', () => {
|
||||||
assert.equal(pickMailSession(["other-1", "mail-def"], "abc"), undefined);
|
assert.equal(pickMailSession(['other-1', 'mail-def'], 'abc'), undefined);
|
||||||
assert.equal(pickMailSession([], "abc"), undefined);
|
assert.equal(pickMailSession([], 'abc'), undefined);
|
||||||
assert.equal(pickMailSession(undefined, "abc"), undefined);
|
assert.equal(pickMailSession(undefined, 'abc'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("接管的平台会话推不出 id:不能被误判成邮件会话", () => {
|
test('接管的平台会话推不出 id:不能被误判成邮件会话', () => {
|
||||||
// 平台自己生成的 id(如 DSH 界面里开的会话)与邮件会话无关,
|
// 平台自己生成的 id(如 DSH 界面里开的会话)与邮件会话无关,
|
||||||
// 匹配函数必须说「不是」,否则会给错误的会话改档位。
|
// 匹配函数必须说「不是」,否则会给错误的会话改档位。
|
||||||
assert.equal(matchesMailSession("session-7f3a91", "abc"), false);
|
assert.equal(matchesMailSession('session-7f3a91', 'abc'), false);
|
||||||
assert.equal(pickMailSession(["session-7f3a91"], "abc"), undefined);
|
assert.equal(pickMailSession(['session-7f3a91'], 'abc'), undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,10 +9,10 @@
|
|||||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from 'node:test';
|
||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { createFrameParser } from "../lib/sse-client.js";
|
import { createFrameParser } from '../lib/sse-client.js';
|
||||||
|
|
||||||
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
||||||
function parse(s) {
|
function parse(s) {
|
||||||
@ -23,118 +23,107 @@ function parse(s) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("完整帧一次喂入:正常解析", () => {
|
test('完整帧一次喂入:正常解析', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
|
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' });
|
||||||
assert.equal(events[0].id, "7");
|
assert.equal(events[0].id, '7');
|
||||||
assert.equal(p.lastEventId(), "7");
|
assert.equal(p.lastEventId(), '7');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
|
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
// chunk1 恰好停在 event 行之后、data 行之前
|
// chunk1 恰好停在 event 行之后、data 行之前
|
||||||
const first = p.push("id: 12\nevent: content_delta\n");
|
const first = p.push('id: 12\nevent: content_delta\n');
|
||||||
assert.deepEqual(first, [], "半帧不该派发");
|
assert.deepEqual(first, [], '半帧不该派发');
|
||||||
|
|
||||||
const second = p.push('data: {"x":1}\n\n');
|
const second = p.push('data: {"x":1}\n\n');
|
||||||
assert.equal(
|
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃');
|
||||||
second.length,
|
assert.equal(second[0].event, 'content_delta');
|
||||||
1,
|
assert.equal(p.lastEventId(), '12');
|
||||||
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
|
|
||||||
);
|
|
||||||
assert.equal(second[0].event, "content_delta");
|
|
||||||
assert.equal(p.lastEventId(), "12");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("帧被切在行中间:buffer 保留半行", () => {
|
test('帧被切在行中间:buffer 保留半行', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const a = p.push("event: new_ma");
|
const a = p.push('event: new_ma');
|
||||||
assert.deepEqual(a, []);
|
assert.deepEqual(a, []);
|
||||||
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
||||||
assert.equal(b.length, 1);
|
assert.equal(b.length, 1);
|
||||||
assert.equal(b[0].event, "new_mail");
|
assert.equal(b[0].event, 'new_mail');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("一个 chunk 里多帧连续:全部派发", () => {
|
test('一个 chunk 里多帧连续:全部派发', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push(
|
const events = p.push(
|
||||||
'event: new_mail\ndata: {"n":1}\n\n' +
|
'event: new_mail\ndata: {"n":1}\n\n' +
|
||||||
'event: new_mail\ndata: {"n":2}\n\n' +
|
'event: new_mail\ndata: {"n":2}\n\n' +
|
||||||
'event: session_update\ndata: {"n":3}\n\n',
|
'event: session_update\ndata: {"n":3}\n\n'
|
||||||
);
|
);
|
||||||
assert.equal(events.length, 3);
|
assert.equal(events.length, 3);
|
||||||
assert.deepEqual(
|
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']);
|
||||||
events.map((e) => e.event),
|
|
||||||
["new_mail", "new_mail", "session_update"],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("注释/心跳行被忽略,不影响后续帧", () => {
|
test('注释/心跳行被忽略,不影响后续帧', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("多行 data 用换行拼接", () => {
|
test('多行 data 用换行拼接', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
|
const events = p.push('event: x\ndata: line1\ndata: line2\n\n');
|
||||||
assert.equal(events[0].data, "line1\nline2");
|
assert.equal(events[0].data, 'line1\nline2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CRLF 不被当成事件名或 JSON 的一部分", () => {
|
test('CRLF 不被当成事件名或 JSON 的一部分', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
assert.equal(events[0].id, "3");
|
assert.equal(events[0].id, '3');
|
||||||
assert.deepEqual(parse(events[0].data), { n: 1 });
|
assert.deepEqual(parse(events[0].data), { n: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
|
test('事件 id 只向前推进:重放旧 id 不回退断点', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "10");
|
assert.equal(p.lastEventId(), '10');
|
||||||
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
||||||
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
||||||
assert.equal(
|
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id(是否回退由使用方决定)');
|
||||||
p.lastEventId(),
|
|
||||||
"5",
|
|
||||||
"解析器如实记录当前 id(是否回退由使用方决定)",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("id 在派发前记录:回调抛异常也不丢断点", () => {
|
test('id 在派发前记录:回调抛异常也不丢断点', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "42");
|
assert.equal(p.lastEventId(), '42');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
|
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('data: {"orphan":true}\n\n');
|
const events = p.push('data: {"orphan":true}\n\n');
|
||||||
assert.deepEqual(events, []);
|
assert.deepEqual(events, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
|
test('reset 清缓冲但保留断点(重连后仍能续传)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
||||||
p.push("event: partial"); // 半帧
|
p.push('event: partial'); // 半帧
|
||||||
p.reset();
|
p.reset();
|
||||||
assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
|
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放');
|
||||||
// reset 后半帧不该复活
|
// reset 后半帧不该复活
|
||||||
const after = p.push('data: {"n":2}\n\n');
|
const after = p.push('data: {"n":2}\n\n');
|
||||||
assert.deepEqual(after, []);
|
assert.deepEqual(after, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
|
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "123");
|
assert.equal(p.lastEventId(), '123');
|
||||||
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
||||||
p.setLastEventId("");
|
p.setLastEventId('');
|
||||||
assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
|
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
mkdirSync,
|
|
||||||
existsSync,
|
|
||||||
statSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join, dirname, basename } from "node:path";
|
import { join, dirname, basename } from "node:path";
|
||||||
@ -40,34 +34,17 @@ import {
|
|||||||
shouldSkipAutoRelay,
|
shouldSkipAutoRelay,
|
||||||
} from "./lib/relay-dedup.js";
|
} from "./lib/relay-dedup.js";
|
||||||
import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js";
|
import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js";
|
||||||
import {
|
import { autoRelayDecision, replyInstruction, inboundHeadline } from "./lib/relay-policy.js";
|
||||||
autoRelayDecision,
|
|
||||||
replyInstruction,
|
|
||||||
inboundHeadline,
|
|
||||||
} from "./lib/relay-policy.js";
|
|
||||||
import { clampRelayKey, isPermanentFailure } from "./lib/relay-key.js";
|
import { clampRelayKey, isPermanentFailure } from "./lib/relay-key.js";
|
||||||
import {
|
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from "./lib/bounded.js";
|
||||||
BoundedMap,
|
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
|
||||||
BoundedSet,
|
|
||||||
MAX_TRACKED_MAILS,
|
|
||||||
MAX_TRACKED_SESSIONS,
|
|
||||||
} from "./lib/bounded.js";
|
|
||||||
import {
|
|
||||||
appendRenameProposal,
|
|
||||||
renameProposalNote,
|
|
||||||
} from "./lib/rename-proposal.js";
|
|
||||||
import { createSSEClient } from "./lib/sse-client.js";
|
import { createSSEClient } from "./lib/sse-client.js";
|
||||||
// opencode 原生支持三态权限,免批由它自己记(response:"always"),
|
// opencode 原生支持三态权限,免批由它自己记(response:"always"),
|
||||||
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
|
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
|
||||||
import { isAlwaysDecision, isApproval } from "./lib/permission-grants.js";
|
import { isAlwaysDecision, isApproval } from "./lib/permission-grants.js";
|
||||||
import {
|
import { opencodePermissions, normalizeMode, modeBriefing } from "./lib/permission-mode.js";
|
||||||
opencodePermissions,
|
|
||||||
normalizeMode,
|
|
||||||
modeBriefing,
|
|
||||||
} from "./lib/permission-mode.js";
|
|
||||||
|
|
||||||
const GATEWAY_URL =
|
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
|
||||||
process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
|
|
||||||
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";
|
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";
|
||||||
// 收到邮件后自动开会话处理时使用的模型
|
// 收到邮件后自动开会话处理时使用的模型
|
||||||
const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || "llmsproxy";
|
const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || "llmsproxy";
|
||||||
@ -80,8 +57,7 @@ const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || "AUTO";
|
|||||||
// 2. ~/.agentmail/agent.key(首次安装时本地生成并落盘)
|
// 2. ~/.agentmail/agent.key(首次安装时本地生成并落盘)
|
||||||
// 没有密钥时退回旧的 name/secret 方式,保证老配置不被这次改动打断。
|
// 没有密钥时退回旧的 name/secret 方式,保证老配置不被这次改动打断。
|
||||||
|
|
||||||
const CONFIG_DIR =
|
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail");
|
||||||
process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail");
|
|
||||||
const KEY_FILE = join(CONFIG_DIR, "agent.key");
|
const KEY_FILE = join(CONFIG_DIR, "agent.key");
|
||||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||||
|
|
||||||
@ -92,9 +68,7 @@ function readLocalKey() {
|
|||||||
try {
|
try {
|
||||||
if (!existsSync(KEY_FILE)) return null;
|
if (!existsSync(KEY_FILE)) return null;
|
||||||
const raw = JSON.parse(readFileSync(KEY_FILE, "utf8"));
|
const raw = JSON.parse(readFileSync(KEY_FILE, "utf8"));
|
||||||
return typeof raw?.key_token === "string" && raw.key_token
|
return typeof raw?.key_token === "string" && raw.key_token ? raw.key_token : null;
|
||||||
? raw.key_token
|
|
||||||
: null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -106,17 +80,11 @@ function generateLocalKey() {
|
|||||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
KEY_FILE,
|
KEY_FILE,
|
||||||
JSON.stringify(
|
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||||
{ key_token: token, created_at: new Date().toISOString() },
|
{ mode: 0o600 }
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
);
|
||||||
console.error(`[mail-bridge] 已在 ${KEY_FILE} 生成本地密钥。`);
|
console.error(`[mail-bridge] 已在 ${KEY_FILE} 生成本地密钥。`);
|
||||||
console.error(
|
console.error(`[mail-bridge] 该密钥需管理员在 AgentMail 后台登记后才能接入:`);
|
||||||
`[mail-bridge] 该密钥需管理员在 AgentMail 后台登记后才能接入:`,
|
|
||||||
);
|
|
||||||
console.error(`[mail-bridge] ${token}`);
|
console.error(`[mail-bridge] ${token}`);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@ -127,20 +95,12 @@ function saveConfig(extra) {
|
|||||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
let cur = {};
|
let cur = {};
|
||||||
if (existsSync(CONFIG_FILE)) {
|
if (existsSync(CONFIG_FILE)) {
|
||||||
try {
|
try { cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch { /* 损坏就重写 */ }
|
||||||
cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
||||||
} catch {
|
|
||||||
/* 损坏就重写 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
JSON.stringify(
|
JSON.stringify({ ...cur, gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, ...extra }, null, 2),
|
||||||
{ ...cur, gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, ...extra },
|
{ mode: 0o600 }
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[mail-bridge] 写 config.json 失败:", e?.message || e);
|
console.error("[mail-bridge] 写 config.json 失败:", e?.message || e);
|
||||||
@ -161,9 +121,7 @@ function authHeaders() {
|
|||||||
// ─── HTTP ───
|
// ─── HTTP ───
|
||||||
|
|
||||||
async function apiGet(path) {
|
async function apiGet(path) {
|
||||||
const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, {
|
const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { headers: authHeaders() });
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
|
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@ -210,42 +168,22 @@ async function syncSessionNaming(mailSessionID, { alias, title }) {
|
|||||||
// ─── Tools(直接用 zod 定义,不依赖 @opencode-ai/plugin) ───
|
// ─── Tools(直接用 zod 定义,不依赖 @opencode-ai/plugin) ───
|
||||||
|
|
||||||
const sendMailTool = {
|
const sendMailTool = {
|
||||||
description:
|
description: "发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建会话,.具体别名 必须是已存在的会话(否则无法送达)。回复来信请传 reply_to。",
|
||||||
"发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建会话,.具体别名 必须是已存在的会话(否则无法送达)。回复来信请传 reply_to。",
|
|
||||||
args: {
|
args: {
|
||||||
to: z
|
to: z.string().describe("收件人:name / name@path(默认会话)/ name@path.new(新建)/ name@path.别名(已有会话)"),
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"收件人:name / name@path(默认会话)/ name@path.new(新建)/ name@path.别名(已有会话)",
|
|
||||||
),
|
|
||||||
subject: z.string().describe("邮件主题"),
|
subject: z.string().describe("邮件主题"),
|
||||||
body: z.string().describe("邮件正文(Markdown)"),
|
body: z.string().describe("邮件正文(Markdown)"),
|
||||||
cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"),
|
cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"),
|
||||||
reply_to: z
|
reply_to: z.string().optional().describe("回复某封邮件时传其 mail_id,回信会落回同一会话"),
|
||||||
.string()
|
session_alias: z.string().optional().describe("仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈。别名全局唯一,不可含 . / @ 空白,不可为 new"),
|
||||||
.optional()
|
attachment_ids: z.array(z.string()).optional().describe("附件 ID 列表,先用 upload_attachment 上传取得"),
|
||||||
.describe("回复某封邮件时传其 mail_id,回信会落回同一会话"),
|
propose_alias: z.string()
|
||||||
session_alias: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈。别名全局唯一,不可含 . / @ 空白,不可为 new",
|
|
||||||
),
|
|
||||||
attachment_ids: z
|
|
||||||
.array(z.string())
|
|
||||||
.optional()
|
|
||||||
.describe("附件 ID 列表,先用 upload_attachment 上传取得"),
|
|
||||||
propose_alias: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"建议把当前会话改名成这个别名(例如摸清问题后从 witty-planet 改成 fix-login-leak)。" +
|
"建议把当前会话改名成这个别名(例如摸清问题后从 witty-planet 改成 fix-login-leak)。" +
|
||||||
"这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new",
|
"这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new"
|
||||||
),
|
),
|
||||||
propose_reason: z
|
propose_reason: z.string().optional().describe("改名理由,一句话,展示给用户看"),
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("改名理由,一句话,展示给用户看"),
|
|
||||||
},
|
},
|
||||||
// context 带 sessionID:用它记下「模型这一轮亲手发过信」,
|
// context 带 sessionID:用它记下「模型这一轮亲手发过信」,
|
||||||
// 让 session.idle 的自动转发让位,避免同一件事发两封。
|
// 让 session.idle 的自动转发让位,避免同一件事发两封。
|
||||||
@ -255,10 +193,7 @@ const sendMailTool = {
|
|||||||
// (三平台共用)—— 各写一遍的话少个空格就静默失效:邮件照常发出,
|
// (三平台共用)—— 各写一遍的话少个空格就静默失效:邮件照常发出,
|
||||||
// 提议凭空消失,而模型以为自己提过了。
|
// 提议凭空消失,而模型以为自己提过了。
|
||||||
const { body, proposed } = appendRenameProposal(
|
const { body, proposed } = appendRenameProposal(
|
||||||
args.body,
|
args.body, args.propose_alias, args.propose_reason);
|
||||||
args.propose_alias,
|
|
||||||
args.propose_reason,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await apiPost("/mail/send", {
|
const result = await apiPost("/mail/send", {
|
||||||
to: args.to,
|
to: args.to,
|
||||||
@ -289,15 +224,9 @@ const sendMailTool = {
|
|||||||
: "";
|
: "";
|
||||||
// 别名取服务端回的 rename_proposed:它跑过 normalizeAlias,
|
// 别名取服务端回的 rename_proposed:它跑过 normalizeAlias,
|
||||||
// 回显本地值会让模型记住一个不存在的名字
|
// 回显本地值会让模型记住一个不存在的名字
|
||||||
const note = renameProposalNote(
|
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
|
||||||
result.rename_proposed,
|
return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${budget}` +
|
||||||
args.propose_alias,
|
(note ? `\n${note}` : "");
|
||||||
proposed,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
`已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${budget}` +
|
|
||||||
(note ? `\n${note}` : "")
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -310,14 +239,8 @@ const forwardMailTool = {
|
|||||||
to: z.string().describe("新收件人的三维地址"),
|
to: z.string().describe("新收件人的三维地址"),
|
||||||
comment: z.string().optional().describe("转发说明,置于引用原文之前"),
|
comment: z.string().optional().describe("转发说明,置于引用原文之前"),
|
||||||
cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"),
|
cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"),
|
||||||
subject: z
|
subject: z.string().optional().describe("自定义主题;留空则自动加 Fwd: 前缀"),
|
||||||
.string()
|
session_alias: z.string().optional().describe("仅在目标地址以 .new 结尾时生效:给新会话命名"),
|
||||||
.optional()
|
|
||||||
.describe("自定义主题;留空则自动加 Fwd: 前缀"),
|
|
||||||
session_alias: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("仅在目标地址以 .new 结尾时生效:给新会话命名"),
|
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const result = await apiPost(`/mail/${args.mail_id}/forward`, {
|
const result = await apiPost(`/mail/${args.mail_id}/forward`, {
|
||||||
@ -334,10 +257,7 @@ const forwardMailTool = {
|
|||||||
const readInboxTool = {
|
const readInboxTool = {
|
||||||
description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。",
|
description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。",
|
||||||
args: {
|
args: {
|
||||||
filter: z
|
filter: z.enum(["unread", "all"]).optional().describe("过滤条件,默认 unread"),
|
||||||
.enum(["unread", "all"])
|
|
||||||
.optional()
|
|
||||||
.describe("过滤条件,默认 unread"),
|
|
||||||
limit: z.number().optional().describe("返回数量,默认 5"),
|
limit: z.number().optional().describe("返回数量,默认 5"),
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
@ -357,8 +277,8 @@ const readInboxTool = {
|
|||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
// 标记失败不该让 read_inbox 失败 —— 正文已经取到了,
|
// 标记失败不该让 read_inbox 失败 —— 正文已经取到了,
|
||||||
// 代价只是下次会重复看到,比丢掉这次读取轻。
|
// 代价只是下次会重复看到,比丢掉这次读取轻。
|
||||||
apiPost("/mail/read", { mail_ids: ids }).catch((e) =>
|
apiPost("/mail/read", { mail_ids: ids }).catch(e =>
|
||||||
console.error("[mail-bridge] 标记已读失败:", e?.message || e),
|
console.error("[mail-bridge] 标记已读失败:", e?.message || e)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return listed;
|
return listed;
|
||||||
@ -372,10 +292,7 @@ const uploadAttachmentTool = {
|
|||||||
"未随邮件发出的附件 24 小时后自动清理。",
|
"未随邮件发出的附件 24 小时后自动清理。",
|
||||||
args: {
|
args: {
|
||||||
path: z.string().describe("要上传的本地文件绝对路径"),
|
path: z.string().describe("要上传的本地文件绝对路径"),
|
||||||
filename: z
|
filename: z.string().optional().describe("自定义展示文件名,默认取路径的最后一段"),
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("自定义展示文件名,默认取路径的最后一段"),
|
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const filePath = args.path;
|
const filePath = args.path;
|
||||||
@ -403,27 +320,21 @@ const uploadAttachmentTool = {
|
|||||||
if (!res.ok) throw new Error(data.error || `上传失败: ${res.status}`);
|
if (!res.ok) throw new Error(data.error || `上传失败: ${res.status}`);
|
||||||
|
|
||||||
const a = data.attachment;
|
const a = data.attachment;
|
||||||
return (
|
return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` +
|
||||||
`已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` +
|
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`;
|
||||||
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadAttachmentTool = {
|
const downloadAttachmentTool = {
|
||||||
description:
|
description: "下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。",
|
||||||
"下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。",
|
|
||||||
args: {
|
args: {
|
||||||
attachment_id: z.string().describe("附件 ID"),
|
attachment_id: z.string().describe("附件 ID"),
|
||||||
save_to: z.string().describe("保存到的本地绝对路径"),
|
save_to: z.string().describe("保存到的本地绝对路径"),
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const res = await fetch(
|
const res = await fetch(`${GATEWAY_URL}/api/v1/attachments/${args.attachment_id}`, {
|
||||||
`${GATEWAY_URL}/api/v1/attachments/${args.attachment_id}`,
|
|
||||||
{
|
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || `下载失败: ${res.status}`);
|
throw new Error(data.error || `下载失败: ${res.status}`);
|
||||||
@ -492,16 +403,10 @@ const sessionParticipantsTool = {
|
|||||||
"列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," +
|
"列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址," +
|
||||||
"并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
|
"并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
|
||||||
args: {
|
args: {
|
||||||
session_id: z
|
session_id: z.string().describe("会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)"),
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"会话 ID(read_inbox 未直接给出时可从 read_thread 或新邮件通知取得)",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const data = await apiGet(
|
const data = await apiGet(`/agent/sessions/${args.session_id}/participants`);
|
||||||
`/agent/sessions/${args.session_id}/participants`,
|
|
||||||
);
|
|
||||||
return renderParticipants(data);
|
return renderParticipants(data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -512,10 +417,7 @@ const readThreadTool = {
|
|||||||
"用它确认别人已经说了什么,避免重复提问或重复汇报。",
|
"用它确认别人已经说了什么,避免重复提问或重复汇报。",
|
||||||
args: {
|
args: {
|
||||||
mail_id: z.string().describe("线索中任一封邮件的 ID"),
|
mail_id: z.string().describe("线索中任一封邮件的 ID"),
|
||||||
offset: z
|
offset: z.number().optional().describe("分页偏移,续取时传上次返回的 next_offset"),
|
||||||
.number()
|
|
||||||
.optional()
|
|
||||||
.describe("分页偏移,续取时传上次返回的 next_offset"),
|
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const qs = args.offset ? `?offset=${args.offset}` : "";
|
const qs = args.offset ? `?offset=${args.offset}` : "";
|
||||||
@ -541,16 +443,13 @@ const readMailTool = {
|
|||||||
`会话: #${data.session_alias || "未命名"}(session_id: ${m.session_id || "?"})`,
|
`会话: #${data.session_alias || "未命名"}(session_id: ${m.session_id || "?"})`,
|
||||||
];
|
];
|
||||||
if (Array.isArray(m.cc_list) && m.cc_list.length) {
|
if (Array.isArray(m.cc_list) && m.cc_list.length) {
|
||||||
lines.push(`抄送: ${m.cc_list.map((c) => c?.raw || c?.name).join("、")}`);
|
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join("、")}`);
|
||||||
}
|
}
|
||||||
if (Array.isArray(m.attachments) && m.attachments.length) {
|
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`附件: ${m.attachments
|
`附件: ${m.attachments
|
||||||
.map(
|
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||||
(a) =>
|
.join("、")}`
|
||||||
`${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`,
|
|
||||||
)
|
|
||||||
.join("、")}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lines.push("", m.body || "(空正文)", "");
|
lines.push("", m.body || "(空正文)", "");
|
||||||
@ -559,15 +458,13 @@ const readMailTool = {
|
|||||||
lines.push(
|
lines.push(
|
||||||
"可投递地址: " +
|
"可投递地址: " +
|
||||||
data.participants
|
data.participants
|
||||||
.filter((p) => p.address && p.name !== AGENT_NAME)
|
.filter(p => p.address && p.name !== AGENT_NAME)
|
||||||
.map((p) => `${p.address}(${p.role})`)
|
.map(p => `${p.address}(${p.role})`)
|
||||||
.join("、"),
|
.join("、")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (data.reply_address) {
|
if (data.reply_address) {
|
||||||
lines.push(
|
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
|
||||||
`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
},
|
},
|
||||||
@ -601,16 +498,8 @@ const connectToServerTool = {
|
|||||||
"连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" +
|
"连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" +
|
||||||
"密钥若未在后台登记过,此处会返回需要登记的密钥全文。",
|
"密钥若未在后台登记过,此处会返回需要登记的密钥全文。",
|
||||||
args: {
|
args: {
|
||||||
gateway_url: z
|
gateway_url: z.string().optional().describe("Gateway 地址,如 https://mail.example.com;省略则用当前配置"),
|
||||||
.string()
|
key_token: z.string().optional().describe("管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)"),
|
||||||
.optional()
|
|
||||||
.describe("Gateway 地址,如 https://mail.example.com;省略则用当前配置"),
|
|
||||||
key_token: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
if (args.key_token) {
|
if (args.key_token) {
|
||||||
@ -619,12 +508,8 @@ const connectToServerTool = {
|
|||||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
KEY_FILE,
|
KEY_FILE,
|
||||||
JSON.stringify(
|
JSON.stringify({ key_token: AGENT_KEY, created_at: new Date().toISOString() }, null, 2),
|
||||||
{ key_token: AGENT_KEY, created_at: new Date().toISOString() },
|
{ mode: 0o600 }
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
);
|
||||||
} else if (!AGENT_KEY) {
|
} else if (!AGENT_KEY) {
|
||||||
AGENT_KEY = generateLocalKey();
|
AGENT_KEY = generateLocalKey();
|
||||||
@ -633,15 +518,8 @@ const connectToServerTool = {
|
|||||||
const url = (args.gateway_url || GATEWAY_URL).replace(/\/+$/, "");
|
const url = (args.gateway_url || GATEWAY_URL).replace(/\/+$/, "");
|
||||||
const res = await fetch(`${url}/api/v1/agent/register`, {
|
const res = await fetch(`${url}/api/v1/agent/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${AGENT_KEY}` },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: "opencode" }),
|
||||||
Authorization: `Bearer ${AGENT_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: AGENT_NAME,
|
|
||||||
workspaces: [],
|
|
||||||
platform: "opencode",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
@ -751,8 +629,7 @@ async function resolveSessionForMail(client, directory, data, kind) {
|
|||||||
} catch {
|
} catch {
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
if (!ok)
|
if (!ok) throw new Error(adoptMissingMessage(adoptedID, "可能已在界面上删除"));
|
||||||
throw new Error(adoptMissingMessage(adoptedID, "可能已在界面上删除"));
|
|
||||||
if (mailSessionID) {
|
if (mailSessionID) {
|
||||||
sessionMap.set(mailSessionID, adoptedID);
|
sessionMap.set(mailSessionID, adoptedID);
|
||||||
reverseMap.set(adoptedID, mailSessionID);
|
reverseMap.set(adoptedID, mailSessionID);
|
||||||
@ -760,9 +637,7 @@ async function resolveSessionForMail(client, directory, data, kind) {
|
|||||||
// 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。
|
// 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。
|
||||||
mailDrivenSessions.add(adoptedID);
|
mailDrivenSessions.add(adoptedID);
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(`[mail-bridge] 接管平台会话 ${adoptedID}(邮件会话 ${mailSessionID})`);
|
||||||
`[mail-bridge] 接管平台会话 ${adoptedID}(邮件会话 ${mailSessionID})`,
|
|
||||||
);
|
|
||||||
return { sessionID: adoptedID, reused: true, adopted: true };
|
return { sessionID: adoptedID, reused: true, adopted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -774,14 +649,10 @@ async function resolveSessionForMail(client, directory, data, kind) {
|
|||||||
//
|
//
|
||||||
// 校验逻辑与 DSH 侧共用(lib/workspace.js):目录不存在时不创建、
|
// 校验逻辑与 DSH 侧共用(lib/workspace.js):目录不存在时不创建、
|
||||||
// 拒绝相对路径。opencode 的兜底是插件启动时的 directory。
|
// 拒绝相对路径。opencode 的兜底是插件启动时的 directory。
|
||||||
const { cwd: wantDir, grouped } = resolveWorkspaceCwd(
|
const { cwd: wantDir, grouped } = resolveWorkspaceCwd(data.to_workspace, directory);
|
||||||
data.to_workspace,
|
|
||||||
directory,
|
|
||||||
);
|
|
||||||
if (!grouped && data.to_workspace) {
|
if (!grouped && data.to_workspace) {
|
||||||
console.error(
|
console.error(
|
||||||
`[mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${wantDir || "(平台默认)"}`,
|
`[mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${wantDir || "(平台默认)"}`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
|
// 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
|
||||||
@ -808,8 +679,7 @@ async function resolveSessionForMail(client, directory, data, kind) {
|
|||||||
// 标题要等模型生成,走 session.updated 事件。
|
// 标题要等模型生成,走 session.updated 事件。
|
||||||
if (session.slug) {
|
if (session.slug) {
|
||||||
syncSessionNaming(mailSessionID, { alias: session.slug }).then((res) => {
|
syncSessionNaming(mailSessionID, { alias: session.slug }).then((res) => {
|
||||||
if (res?.alias)
|
if (res?.alias) console.error(`[mail-bridge] 别名同步 ${sessionID} -> ${res.alias}`);
|
||||||
console.error(`[mail-bridge] 别名同步 ${sessionID} -> ${res.alias}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -860,13 +730,11 @@ async function relaySummary(client, directory, sessionID) {
|
|||||||
// 未完成的消息(还在生成/被中断)不转:转出去是半截话
|
// 未完成的消息(还在生成/被中断)不转:转出去是半截话
|
||||||
if (!m.info.time?.completed) continue;
|
if (!m.info.time?.completed) continue;
|
||||||
const text = (m.parts || [])
|
const text = (m.parts || [])
|
||||||
.filter((p) => p.type === "text" && !p.synthetic && !p.ignored && p.text)
|
.filter(p => p.type === "text" && !p.synthetic && !p.ignored && p.text)
|
||||||
.map((p) => p.text)
|
.map(p => p.text)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.trim();
|
.trim();
|
||||||
if (text) {
|
if (text) { last = { id: m.info.id, text }; }
|
||||||
last = { id: m.info.id, text };
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!last) {
|
if (!last) {
|
||||||
@ -907,14 +775,10 @@ async function relaySummary(client, directory, sessionID) {
|
|||||||
// - reply_to 相同:它已经回过这封信了
|
// - reply_to 相同:它已经回过这封信了
|
||||||
// relay_key 的幂等管不了这个 —— 那个键保证「同一条消息不转两次」,
|
// relay_key 的幂等管不了这个 —— 那个键保证「同一条消息不转两次」,
|
||||||
// 而这里是「模型已经自己发过了」。
|
// 而这里是「模型已经自己发过了」。
|
||||||
if (
|
if (shouldSkipAutoRelay(explicitSends.get(sessionID), ctx.replyTo, ctx.mailID)) {
|
||||||
shouldSkipAutoRelay(explicitSends.get(sessionID), ctx.replyTo, ctx.mailID)
|
|
||||||
) {
|
|
||||||
explicitSends.delete(sessionID);
|
explicitSends.delete(sessionID);
|
||||||
relayedSummaries.set(sessionID, last.id); // 记下这条已「处理」,别下次 idle 又转
|
relayedSummaries.set(sessionID, last.id); // 记下这条已「处理」,别下次 idle 又转
|
||||||
console.error(
|
console.error(`[mail-bridge] 本轮模型已主动回信 ${ctx.replyTo},跳过自动转发`);
|
||||||
`[mail-bridge] 本轮模型已主动回信 ${ctx.replyTo},跳过自动转发`,
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1001,10 +865,8 @@ async function replyPermission(client, directory, data) {
|
|||||||
// (空串、历史数据里的旧选项、将来服务端新增的选项)都会放行一次
|
// (空串、历史数据里的旧选项、将来服务端新增的选项)都会放行一次
|
||||||
// 没人批准的危险操作。判断顺序也重要:always 必须先判,因为 isApproval
|
// 没人批准的危险操作。判断顺序也重要:always 必须先判,因为 isApproval
|
||||||
// 对「一直同意」同样为真。
|
// 对「一直同意」同样为真。
|
||||||
const response = isAlwaysDecision(decision)
|
const response = isAlwaysDecision(decision) ? "always"
|
||||||
? "always"
|
: isApproval(decision) ? "once"
|
||||||
: isApproval(decision)
|
|
||||||
? "once"
|
|
||||||
: "reject";
|
: "reject";
|
||||||
|
|
||||||
await client.postSessionIdPermissionsPermissionId({
|
await client.postSessionIdPermissionsPermissionId({
|
||||||
@ -1013,20 +875,13 @@ async function replyPermission(client, directory, data) {
|
|||||||
body: { response },
|
body: { response },
|
||||||
});
|
});
|
||||||
pendingPermissions.delete(permID);
|
pendingPermissions.delete(permID);
|
||||||
console.error(
|
console.error(`[mail-bridge] 权限 ${permID} -> ${response}(决策人 ${data.decided_by || "?"})`);
|
||||||
`[mail-bridge] 权限 ${permID} -> ${response}(决策人 ${data.decided_by || "?"})`,
|
|
||||||
);
|
|
||||||
return { sessionID };
|
return { sessionID };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把一封来信投递给对应的 opencode 会话(已绑定则续谈,未绑定则新开)。
|
// 把一封来信投递给对应的 opencode 会话(已绑定则续谈,未绑定则新开)。
|
||||||
async function deliverMail(client, directory, data, kind) {
|
async function deliverMail(client, directory, data, kind) {
|
||||||
const { sessionID, reused } = await resolveSessionForMail(
|
const { sessionID, reused } = await resolveSessionForMail(client, directory, data, kind);
|
||||||
client,
|
|
||||||
directory,
|
|
||||||
data,
|
|
||||||
kind,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 新一轮开始:清掉上一轮「模型主动发过信」的记录。
|
// 新一轮开始:清掉上一轮「模型主动发过信」的记录。
|
||||||
// 不清的话,上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。
|
// 不清的话,上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。
|
||||||
@ -1052,13 +907,8 @@ async function deliverMail(client, directory, data, kind) {
|
|||||||
const fromHuman = data.from_human === true;
|
const fromHuman = data.from_human === true;
|
||||||
// opencode 1.18+ 不支持 session.create permission 参数,
|
// opencode 1.18+ 不支持 session.create permission 参数,
|
||||||
// 只能在提示词里告知模型档位约束(advisory 路径)。
|
// 只能在提示词里告知模型档位约束(advisory 路径)。
|
||||||
const permBriefing = modeBriefing({
|
const permBriefing = modeBriefing({ mode: normalizeMode(data.permission_mode), enforcement: 'advisory', workspace: directory || '' });
|
||||||
mode: normalizeMode(data.permission_mode),
|
const text = kind === "permission"
|
||||||
enforcement: "advisory",
|
|
||||||
workspace: directory || "",
|
|
||||||
});
|
|
||||||
const text =
|
|
||||||
kind === "permission"
|
|
||||||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。`
|
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。`
|
||||||
: [
|
: [
|
||||||
inboundHeadline({
|
inboundHeadline({
|
||||||
@ -1105,9 +955,7 @@ async function deliverMail(client, directory, data, kind) {
|
|||||||
query: directory ? { directory } : undefined,
|
query: directory ? { directory } : undefined,
|
||||||
body: {
|
body: {
|
||||||
// route 为 undefined 表示不指定模型,交给平台自己选
|
// route 为 undefined 表示不指定模型,交给平台自己选
|
||||||
...(route
|
...(route ? { model: { providerID: route.provider, modelID: route.model } } : {}),
|
||||||
? { model: { providerID: route.provider, modelID: route.model } }
|
|
||||||
: {}),
|
|
||||||
parts: [{ type: "text", text }],
|
parts: [{ type: "text", text }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -1122,9 +970,7 @@ async function deliverMail(client, directory, data, kind) {
|
|||||||
const outcome = await watching.result;
|
const outcome = await watching.result;
|
||||||
if (outcome.ok) {
|
if (outcome.ok) {
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.error(
|
console.error(`[mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
|
||||||
`[mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return { sessionID, reused };
|
return { sessionID, reused };
|
||||||
}
|
}
|
||||||
@ -1151,8 +997,7 @@ async function deliverMail(client, directory, data, kind) {
|
|||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`划定范围内的 ${failures.length} 个模型全部失败:` +
|
`划定范围内的 ${failures.length} 个模型全部失败:` +
|
||||||
failures.map((f) => f.error).join(" | "),
|
failures.map(f => f.error).join(" | "));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 首轮结果观察 ───
|
// ─── 首轮结果观察 ───
|
||||||
@ -1173,9 +1018,7 @@ const turnWatchers = new Map(); // sessionID -> { resolve, timer }
|
|||||||
*/
|
*/
|
||||||
function awaitFirstTurn(sessionID, timeoutMs = 60000) {
|
function awaitFirstTurn(sessionID, timeoutMs = 60000) {
|
||||||
let settle;
|
let settle;
|
||||||
const result = new Promise((res) => {
|
const result = new Promise(res => { settle = res; });
|
||||||
settle = res;
|
|
||||||
});
|
|
||||||
const finish = (r) => {
|
const finish = (r) => {
|
||||||
const w = turnWatchers.get(sessionID);
|
const w = turnWatchers.get(sessionID);
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
@ -1219,16 +1062,14 @@ export default async function mailBridge(input) {
|
|||||||
saveConfig({ registered_at: new Date().toISOString() });
|
saveConfig({ registered_at: new Date().toISOString() });
|
||||||
console.error(
|
console.error(
|
||||||
`[mail-bridge] 已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}` +
|
`[mail-bridge] 已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}` +
|
||||||
`(${AGENT_KEY ? "密钥认证" : "name/secret 认证"})。`,
|
`(${AGENT_KEY ? "密钥认证" : "name/secret 认证"})。`
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 密钥未登记时这里会报「密钥无效」——必须说清楚该做什么,
|
// 密钥未登记时这里会报「密钥无效」——必须说清楚该做什么,
|
||||||
// 否则用户只看到一句 401 不知道要拿密钥去后台登记。
|
// 否则用户只看到一句 401 不知道要拿密钥去后台登记。
|
||||||
console.error("[mail-bridge] 注册失败:", e?.message);
|
console.error("[mail-bridge] 注册失败:", e?.message);
|
||||||
if (AGENT_KEY) {
|
if (AGENT_KEY) {
|
||||||
console.error(
|
console.error(`[mail-bridge] 若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥(见 ${KEY_FILE})。`);
|
||||||
`[mail-bridge] 若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥(见 ${KEY_FILE})。`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1251,9 +1092,7 @@ export default async function mailBridge(input) {
|
|||||||
query: directory ? { directory } : undefined,
|
query: directory ? { directory } : undefined,
|
||||||
});
|
});
|
||||||
const sessions = listed?.data ?? listed ?? [];
|
const sessions = listed?.data ?? listed ?? [];
|
||||||
return snapshotOpencodeSessions(sessions, (id) =>
|
return snapshotOpencodeSessions(sessions, (id) => mailDrivenSessions.has(id));
|
||||||
mailDrivenSessions.has(id),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 拉不到列表就**省略**该字段,而不是传空数组:
|
// 拉不到列表就**省略**该字段,而不是传空数组:
|
||||||
// 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。
|
// 空数组的语义是「平台侧确实一条会话都没有」,会把服务端的镜像抹掉。
|
||||||
@ -1298,9 +1137,7 @@ export default async function mailBridge(input) {
|
|||||||
const box = await apiGet("/mail/inbox?status=unread&limit=20");
|
const box = await apiGet("/mail/inbox?status=unread&limit=20");
|
||||||
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
|
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
|
||||||
if (tasks.length === 0) return;
|
if (tasks.length === 0) return;
|
||||||
console.error(
|
console.error(`[mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`);
|
||||||
`[mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`,
|
|
||||||
);
|
|
||||||
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
|
// 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
|
||||||
for (const ev of tasks) {
|
for (const ev of tasks) {
|
||||||
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封
|
// 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封
|
||||||
@ -1310,10 +1147,7 @@ export default async function mailBridge(input) {
|
|||||||
try {
|
try {
|
||||||
await deliverMail(client, directory, ev, "mail");
|
await deliverMail(client, directory, ev, "mail");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(`[mail-bridge] 补投 ${ev.mail_id} 失败:`, e?.message || e);
|
||||||
`[mail-bridge] 补投 ${ev.mail_id} 失败:`,
|
|
||||||
e?.message || e,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1331,15 +1165,14 @@ export default async function mailBridge(input) {
|
|||||||
const body = {};
|
const body = {};
|
||||||
// opencode 1.18+ 的 session.create 不支持 permission 参数,
|
// opencode 1.18+ 的 session.create 不支持 permission 参数,
|
||||||
// 权限只能通过提示词告知模型(advisory)。
|
// 权限只能通过提示词告知模型(advisory)。
|
||||||
body.mode_enforcement = "advisory";
|
body.mode_enforcement = 'advisory';
|
||||||
if (platform_sessions) body.platform_sessions = platform_sessions;
|
if (platform_sessions) body.platform_sessions = platform_sessions;
|
||||||
if (models) body.models = models;
|
if (models) body.models = models;
|
||||||
try {
|
try {
|
||||||
const res = await apiPost("/agent/heartbeat", body);
|
const res = await apiPost("/agent/heartbeat", body);
|
||||||
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
|
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
|
||||||
// 插件最多一个周期(30 秒)就能看到新值,不需要重启。
|
// 插件最多一个周期(30 秒)就能看到新值,不需要重启。
|
||||||
if (Array.isArray(res?.allowed_models))
|
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models;
|
||||||
allowedModels = res.allowed_models;
|
|
||||||
// 只在首个成功的心跳后补投一次:之后的积压都由 SSE 覆盖,
|
// 只在首个成功的心跳后补投一次:之后的积压都由 SSE 覆盖,
|
||||||
// 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。
|
// 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。
|
||||||
if (!caughtUp) {
|
if (!caughtUp) {
|
||||||
@ -1375,9 +1208,7 @@ export default async function mailBridge(input) {
|
|||||||
if (data?.mail_id) deliveredMails.add(data.mail_id);
|
if (data?.mail_id) deliveredMails.add(data.mail_id);
|
||||||
deliverMail(client, directory, data, "mail")
|
deliverMail(client, directory, data, "mail")
|
||||||
.then(({ sessionID, reused }) => {
|
.then(({ sessionID, reused }) => {
|
||||||
console.error(
|
console.error(`[mail-bridge] ${type} -> ${reused ? "续谈" : "新会话"} ${sessionID}`);
|
||||||
`[mail-bridge] ${type} -> ${reused ? "续谈" : "新会话"} ${sessionID}`,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
// 失败必须可见,否则邮件会静默丢失
|
// 失败必须可见,否则邮件会静默丢失
|
||||||
@ -1417,22 +1248,14 @@ export default async function mailBridge(input) {
|
|||||||
options: ["同意", "一直同意", "拒绝"],
|
options: ["同意", "一直同意", "拒绝"],
|
||||||
context: [
|
context: [
|
||||||
`类型:${input.type}`,
|
`类型:${input.type}`,
|
||||||
input.pattern
|
input.pattern ? `目标:${Array.isArray(input.pattern) ? input.pattern.join(", ") : input.pattern}` : "",
|
||||||
? `目标:${Array.isArray(input.pattern) ? input.pattern.join(", ") : input.pattern}`
|
|
||||||
: "",
|
|
||||||
Object.keys(input.metadata || {}).length
|
Object.keys(input.metadata || {}).length
|
||||||
? "\n```json\n" +
|
? "\n```json\n" + JSON.stringify(input.metadata, null, 2) + "\n```"
|
||||||
JSON.stringify(input.metadata, null, 2) +
|
|
||||||
"\n```"
|
|
||||||
: "",
|
: "",
|
||||||
]
|
].filter(Boolean).join("\n"),
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
relayKey: clampRelayKey(input.id),
|
relayKey: clampRelayKey(input.id),
|
||||||
});
|
});
|
||||||
console.error(
|
console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`);
|
||||||
`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pendingPermissions.delete(input.id);
|
pendingPermissions.delete(input.id);
|
||||||
|
|
||||||
@ -1458,20 +1281,14 @@ export default async function mailBridge(input) {
|
|||||||
output.reason = [
|
output.reason = [
|
||||||
b.error || `无法把授权请求送达给人类(HTTP ${e?.status})`,
|
b.error || `无法把授权请求送达给人类(HTTP ${e?.status})`,
|
||||||
b.detail || "",
|
b.detail || "",
|
||||||
b.suggestion ||
|
b.suggestion || "这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。",
|
||||||
"这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。",
|
].filter(Boolean).join("\n");
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暂时失败(5xx / 408 / 429 / 网络拖动)保持 ask:那些真的可能下一次就好,
|
// 暂时失败(5xx / 408 / 429 / 网络拖动)保持 ask:那些真的可能下一次就好,
|
||||||
// 人也仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
|
// 人也仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
|
||||||
console.error(
|
console.error("[mail-bridge] 权限询问转发暂时失败(保持 ask):", e?.message || e);
|
||||||
"[mail-bridge] 权限询问转发暂时失败(保持 ask):",
|
|
||||||
e?.message || e,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
output.status = "ask";
|
output.status = "ask";
|
||||||
@ -1495,14 +1312,9 @@ export default async function mailBridge(input) {
|
|||||||
if (syncedTitles.get(info.id) === title) return;
|
if (syncedTitles.get(info.id) === title) return;
|
||||||
syncedTitles.set(info.id, title);
|
syncedTitles.set(info.id, title);
|
||||||
|
|
||||||
const res = await syncSessionNaming(mailSessionID, {
|
const res = await syncSessionNaming(mailSessionID, { alias: info.slug || "", title });
|
||||||
alias: info.slug || "",
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
if (res) {
|
if (res) {
|
||||||
console.error(
|
console.error(`[mail-bridge] 会话命名同步 ${info.id} -> alias=${res.alias || "-"} title=${res.title || "-"}`);
|
||||||
`[mail-bridge] 会话命名同步 ${info.id} -> alias=${res.alias || "-"} title=${res.title || "-"}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1515,8 +1327,7 @@ export default async function mailBridge(input) {
|
|||||||
if (event?.type === "session.error") {
|
if (event?.type === "session.error") {
|
||||||
const sid = event.properties?.sessionID;
|
const sid = event.properties?.sessionID;
|
||||||
const err = event.properties?.error;
|
const err = event.properties?.error;
|
||||||
const msg =
|
const msg = err?.data?.message || err?.name || JSON.stringify(err ?? {});
|
||||||
err?.data?.message || err?.name || JSON.stringify(err ?? {});
|
|
||||||
if (sid && settleFirstTurn(sid, { ok: false, error: msg })) {
|
if (sid && settleFirstTurn(sid, { ok: false, error: msg })) {
|
||||||
return; // 正在降级尝试中,不当作一次普通故障
|
return; // 正在降级尝试中,不当作一次普通故障
|
||||||
}
|
}
|
||||||
@ -1534,9 +1345,7 @@ export default async function mailBridge(input) {
|
|||||||
try {
|
try {
|
||||||
const res = await relaySummaryRef(sid);
|
const res = await relaySummaryRef(sid);
|
||||||
if (res?.mail_id) {
|
if (res?.mail_id) {
|
||||||
console.error(
|
console.error(`[mail-bridge] 总结已回信 ${res.mail_id}(不计配额)`);
|
||||||
`[mail-bridge] 总结已回信 ${res.mail_id}(不计配额)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[mail-bridge] 总结回信失败:", e?.message || e);
|
console.error("[mail-bridge] 总结回信失败:", e?.message || e);
|
||||||
|
|||||||
@ -34,60 +34,58 @@
|
|||||||
* setLastEventId: (id: string) => void}}
|
* setLastEventId: (id: string) => void}}
|
||||||
*/
|
*/
|
||||||
export function createFrameParser() {
|
export function createFrameParser() {
|
||||||
let buffer = "";
|
let buffer = '';
|
||||||
let lastEventId = "";
|
let lastEventId = '';
|
||||||
let curEvent = "";
|
let curEvent = '';
|
||||||
let curData = "";
|
let curData = '';
|
||||||
let curId = "";
|
let curId = '';
|
||||||
|
|
||||||
function push(chunk) {
|
function push(chunk) {
|
||||||
buffer += chunk;
|
buffer += chunk;
|
||||||
const events = [];
|
const events = [];
|
||||||
const lines = buffer.split("\n");
|
const lines = buffer.split('\n');
|
||||||
// 最后一段可能是被切断的半行,留到下一个 chunk
|
// 最后一段可能是被切断的半行,留到下一个 chunk
|
||||||
buffer = lines.pop() ?? "";
|
buffer = lines.pop() ?? '';
|
||||||
|
|
||||||
for (let line of lines) {
|
for (let line of lines) {
|
||||||
if (line.length > 0 && line.charAt(line.length - 1) === "\r") {
|
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
|
||||||
line = line.slice(0, -1);
|
line = line.slice(0, -1);
|
||||||
}
|
}
|
||||||
if (line.startsWith(":")) continue;
|
if (line.startsWith(':')) continue;
|
||||||
|
|
||||||
if (line.startsWith("id:")) {
|
if (line.startsWith('id:')) {
|
||||||
curId = line.slice(3).trim();
|
curId = line.slice(3).trim();
|
||||||
} else if (line.startsWith("event:")) {
|
} else if (line.startsWith('event:')) {
|
||||||
curEvent = line.slice(6).trim();
|
curEvent = line.slice(6).trim();
|
||||||
} else if (line.startsWith("data:")) {
|
} else if (line.startsWith('data:')) {
|
||||||
let value = line.slice(5);
|
let value = line.slice(5);
|
||||||
if (value.startsWith(" ")) value = value.slice(1);
|
if (value.startsWith(' ')) value = value.slice(1);
|
||||||
curData = curData.length > 0 ? `${curData}\n${value}` : value;
|
curData = curData.length > 0 ? `${curData}\n${value}` : value;
|
||||||
} else if (line === "") {
|
} else if (line === '') {
|
||||||
if (curEvent.length > 0 && curData.length > 0) {
|
if (curEvent.length > 0 && curData.length > 0) {
|
||||||
if (curId.length > 0) lastEventId = curId;
|
if (curId.length > 0) lastEventId = curId;
|
||||||
events.push({ event: curEvent, data: curData, id: curId });
|
events.push({ event: curEvent, data: curData, id: curId });
|
||||||
}
|
}
|
||||||
curEvent = "";
|
curEvent = '';
|
||||||
curData = "";
|
curData = '';
|
||||||
curId = "";
|
curId = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
buffer = "";
|
buffer = '';
|
||||||
curEvent = "";
|
curEvent = '';
|
||||||
curData = "";
|
curData = '';
|
||||||
curId = "";
|
curId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
push,
|
push,
|
||||||
reset,
|
reset,
|
||||||
lastEventId: () => lastEventId,
|
lastEventId: () => lastEventId,
|
||||||
setLastEventId: (id) => {
|
setLastEventId: (id) => { lastEventId = id || ''; },
|
||||||
lastEventId = id || "";
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,13 +98,7 @@ export function createFrameParser() {
|
|||||||
* @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error)
|
* @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error)
|
||||||
* @returns {{stop: () => void}} stop() 终止重连与在途请求
|
* @returns {{stop: () => void}} stop() 终止重连与在途请求
|
||||||
*/
|
*/
|
||||||
export function createSSEClient({
|
export function createSSEClient({ authHeaders, baseURL, path, onEvent, log = console.error }) {
|
||||||
authHeaders,
|
|
||||||
baseURL,
|
|
||||||
path,
|
|
||||||
onEvent,
|
|
||||||
log = console.error,
|
|
||||||
}) {
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const parser = createFrameParser();
|
const parser = createFrameParser();
|
||||||
|
|
||||||
@ -122,12 +114,12 @@ export function createSSEClient({
|
|||||||
function connect() {
|
function connect() {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
const headers = { ...authHeaders(), Accept: "text/event-stream" };
|
const headers = { ...authHeaders(), Accept: 'text/event-stream' };
|
||||||
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
|
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
|
||||||
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||||
const lastEventID = parser.lastEventId();
|
const lastEventID = parser.lastEventId();
|
||||||
if (lastEventID) {
|
if (lastEventID) {
|
||||||
headers["Last-Event-ID"] = lastEventID;
|
headers['Last-Event-ID'] = lastEventID;
|
||||||
log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
|
log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,16 +133,12 @@ export function createSSEClient({
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
function read() {
|
function read() {
|
||||||
reader
|
reader.read().then(({ done, value }) => {
|
||||||
.read()
|
|
||||||
.then(({ done, value }) => {
|
|
||||||
if (done) {
|
if (done) {
|
||||||
parser.reset();
|
parser.reset();
|
||||||
return reconnect(3000);
|
return reconnect(3000);
|
||||||
}
|
}
|
||||||
for (const ev of parser.push(
|
for (const ev of parser.push(decoder.decode(value, { stream: true }))) {
|
||||||
decoder.decode(value, { stream: true }),
|
|
||||||
)) {
|
|
||||||
try {
|
try {
|
||||||
onEvent(ev.event, JSON.parse(ev.data));
|
onEvent(ev.event, JSON.parse(ev.data));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -158,8 +146,7 @@ export function createSSEClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
read();
|
read();
|
||||||
})
|
}).catch((e) => {
|
||||||
.catch((e) => {
|
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
log(`SSE 读取中断: ${e?.message || e}`);
|
log(`SSE 读取中断: ${e?.message || e}`);
|
||||||
parser.reset();
|
parser.reset();
|
||||||
@ -187,6 +174,6 @@ export function createSSEClient({
|
|||||||
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
|
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
|
||||||
* 得到的事件属于别人的会话。
|
* 得到的事件属于别人的会话。
|
||||||
*/
|
*/
|
||||||
reset: () => parser.setLastEventId(""),
|
reset: () => parser.setLastEventId(''),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,18 +39,16 @@ export function hasOptions(question) {
|
|||||||
export function optionLabels(question) {
|
export function optionLabels(question) {
|
||||||
if (!hasOptions(question)) return [];
|
if (!hasOptions(question)) return [];
|
||||||
return question.options
|
return question.options
|
||||||
.map((o) => (typeof o === "string" ? o : o?.label))
|
.map((o) => (typeof o === 'string' ? o : o?.label))
|
||||||
.filter((l) => typeof l === "string" && l.length > 0);
|
.filter((l) => typeof l === 'string' && l.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一个问题的展示标题:header 有就用它做前缀,否则只用 question。 */
|
/** 一个问题的展示标题:header 有就用它做前缀,否则只用 question。 */
|
||||||
export function questionTitle(question) {
|
export function questionTitle(question) {
|
||||||
const header =
|
const header = typeof question?.header === 'string' ? question.header.trim() : '';
|
||||||
typeof question?.header === "string" ? question.header.trim() : "";
|
const text = typeof question?.question === 'string' ? question.question.trim() : '';
|
||||||
const text =
|
|
||||||
typeof question?.question === "string" ? question.question.trim() : "";
|
|
||||||
if (header && text) return `${header}: ${text}`;
|
if (header && text) return `${header}: ${text}`;
|
||||||
return header || text || "(未提供问题)";
|
return header || text || '(未提供问题)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,7 +60,7 @@ export function questionTitle(question) {
|
|||||||
export function flattenQuestions(questions) {
|
export function flattenQuestions(questions) {
|
||||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
throw new Error("ask_user_question 至少需要一个 question");
|
throw new Error('ask_user_question 至少需要一个 question');
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = [];
|
const lines = [];
|
||||||
@ -73,14 +71,12 @@ export function flattenQuestions(questions) {
|
|||||||
list.forEach((q, i) => {
|
list.forEach((q, i) => {
|
||||||
const title = questionTitle(q);
|
const title = questionTitle(q);
|
||||||
lines.push(`${i + 1}. ${title}`);
|
lines.push(`${i + 1}. ${title}`);
|
||||||
const detail = typeof q?.detail === "string" ? q.detail.trim() : "";
|
const detail = typeof q?.detail === 'string' ? q.detail.trim() : '';
|
||||||
if (detail) lines.push(` ${detail}`);
|
if (detail) lines.push(` ${detail}`);
|
||||||
|
|
||||||
const labels = optionLabels(q);
|
const labels = optionLabels(q);
|
||||||
if (labels.length > 0) {
|
if (labels.length > 0) {
|
||||||
lines.push(
|
lines.push(` 可选项:${labels.join(' / ')}${q.multiSelect ? '(可多选)' : ''}`);
|
||||||
` 可选项:${labels.join(" / ")}${q.multiSelect ? "(可多选)" : ""}`,
|
|
||||||
);
|
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
if (!seen.has(label)) {
|
if (!seen.has(label)) {
|
||||||
seen.add(label);
|
seen.add(label);
|
||||||
@ -88,7 +84,7 @@ export function flattenQuestions(questions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.push(" (请直接填写回答)");
|
lines.push(' (请直接填写回答)');
|
||||||
}
|
}
|
||||||
if (q?.multiSelect === true) anyMulti = true;
|
if (q?.multiSelect === true) anyMulti = true;
|
||||||
});
|
});
|
||||||
@ -96,18 +92,15 @@ export function flattenQuestions(questions) {
|
|||||||
// 多问题时必须允许多选:不同问题的选项要能一起勾选。
|
// 多问题时必须允许多选:不同问题的选项要能一起勾选。
|
||||||
const multiSelect = list.length > 1 ? options.length > 0 : anyMulti;
|
const multiSelect = list.length > 1 ? options.length > 0 : anyMulti;
|
||||||
|
|
||||||
const question =
|
const question = list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`;
|
||||||
list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`;
|
|
||||||
const context = [
|
const context = [
|
||||||
list.length === 1 ? "" : "模型提出了多个问题,请在「回复」里一并回答:",
|
list.length === 1 ? '' : '模型提出了多个问题,请在「回复」里一并回答:',
|
||||||
...lines,
|
...lines,
|
||||||
"",
|
'',
|
||||||
options.length > 0
|
options.length > 0
|
||||||
? "可直接勾选下方的选项;补充说明写在备注里。"
|
? '可直接勾选下方的选项;补充说明写在备注里。'
|
||||||
: "这题没有预设选项,请把回答写在备注里。",
|
: '这题没有预设选项,请把回答写在备注里。',
|
||||||
]
|
].filter((l) => l !== '').join('\n');
|
||||||
.filter((l) => l !== "")
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return { question, options, context, multiSelect };
|
return { question, options, context, multiSelect };
|
||||||
}
|
}
|
||||||
@ -122,31 +115,27 @@ export function flattenQuestions(questions) {
|
|||||||
*/
|
*/
|
||||||
export function answersFromDecision(questions, decision, note) {
|
export function answersFromDecision(questions, decision, note) {
|
||||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||||
const labels = String(decision || "")
|
const labels = String(decision || '')
|
||||||
.split("\n")
|
.split('\n')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const custom = typeof note === "string" ? note.trim() : "";
|
const custom = typeof note === 'string' ? note.trim() : '';
|
||||||
|
|
||||||
// 单问题:忠实映射(选项 → selected,备注 → custom)。
|
// 单问题:忠实映射(选项 → selected,备注 → custom)。
|
||||||
if (list.length === 1) {
|
if (list.length === 1) {
|
||||||
const q = list[0];
|
const q = list[0];
|
||||||
const id = String(q?.id ?? "0");
|
const id = String(q?.id ?? '0');
|
||||||
if (!hasOptions(q)) {
|
if (!hasOptions(q)) {
|
||||||
// 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。
|
// 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。
|
||||||
const text = custom || labels.join("\n");
|
const text = custom || labels.join('\n');
|
||||||
return {
|
return { answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }] };
|
||||||
answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
answers: [
|
answers: [{
|
||||||
{
|
|
||||||
id,
|
id,
|
||||||
selected: labels,
|
selected: labels,
|
||||||
...(custom ? { custom } : {}),
|
...(custom ? { custom } : {}),
|
||||||
},
|
}],
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +152,7 @@ export function answersFromDecision(questions, decision, note) {
|
|||||||
}
|
}
|
||||||
// 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。
|
// 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。
|
||||||
if (qCustom === undefined && !hasOptions(q) && note === undefined) {
|
if (qCustom === undefined && !hasOptions(q) && note === undefined) {
|
||||||
const text = labels.join("\n");
|
const text = labels.join('\n');
|
||||||
if (text) qCustom = text;
|
if (text) qCustom = text;
|
||||||
}
|
}
|
||||||
return { id, selected, ...(qCustom ? { custom: qCustom } : {}) };
|
return { id, selected, ...(qCustom ? { custom: qCustom } : {}) };
|
||||||
@ -178,11 +167,8 @@ export function answersFromDecision(questions, decision, note) {
|
|||||||
* 允许多选时空 selected 但有 custom 也算答了;两者都空才算没答。
|
* 允许多选时空 selected 但有 custom 也算答了;两者都空才算没答。
|
||||||
*/
|
*/
|
||||||
export function isBlankAnswer(questions, decision, note) {
|
export function isBlankAnswer(questions, decision, note) {
|
||||||
const labels = String(decision || "")
|
const labels = String(decision || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
||||||
.split("\n")
|
const custom = typeof note === 'string' ? note.trim() : '';
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const custom = typeof note === "string" ? note.trim() : "";
|
|
||||||
if (labels.length > 0 || custom) return false;
|
if (labels.length > 0 || custom) return false;
|
||||||
// 全部问题都没有选项、人也没写字 → 确实什么都没答
|
// 全部问题都没有选项、人也没写字 → 确实什么都没答
|
||||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||||
|
|||||||
@ -9,10 +9,10 @@
|
|||||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from 'node:test';
|
||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { createFrameParser } from "../lib/sse-client.js";
|
import { createFrameParser } from '../lib/sse-client.js';
|
||||||
|
|
||||||
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
||||||
function parse(s) {
|
function parse(s) {
|
||||||
@ -23,118 +23,107 @@ function parse(s) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("完整帧一次喂入:正常解析", () => {
|
test('完整帧一次喂入:正常解析', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
|
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' });
|
||||||
assert.equal(events[0].id, "7");
|
assert.equal(events[0].id, '7');
|
||||||
assert.equal(p.lastEventId(), "7");
|
assert.equal(p.lastEventId(), '7');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
|
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
// chunk1 恰好停在 event 行之后、data 行之前
|
// chunk1 恰好停在 event 行之后、data 行之前
|
||||||
const first = p.push("id: 12\nevent: content_delta\n");
|
const first = p.push('id: 12\nevent: content_delta\n');
|
||||||
assert.deepEqual(first, [], "半帧不该派发");
|
assert.deepEqual(first, [], '半帧不该派发');
|
||||||
|
|
||||||
const second = p.push('data: {"x":1}\n\n');
|
const second = p.push('data: {"x":1}\n\n');
|
||||||
assert.equal(
|
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃');
|
||||||
second.length,
|
assert.equal(second[0].event, 'content_delta');
|
||||||
1,
|
assert.equal(p.lastEventId(), '12');
|
||||||
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
|
|
||||||
);
|
|
||||||
assert.equal(second[0].event, "content_delta");
|
|
||||||
assert.equal(p.lastEventId(), "12");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("帧被切在行中间:buffer 保留半行", () => {
|
test('帧被切在行中间:buffer 保留半行', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const a = p.push("event: new_ma");
|
const a = p.push('event: new_ma');
|
||||||
assert.deepEqual(a, []);
|
assert.deepEqual(a, []);
|
||||||
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
||||||
assert.equal(b.length, 1);
|
assert.equal(b.length, 1);
|
||||||
assert.equal(b[0].event, "new_mail");
|
assert.equal(b[0].event, 'new_mail');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("一个 chunk 里多帧连续:全部派发", () => {
|
test('一个 chunk 里多帧连续:全部派发', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push(
|
const events = p.push(
|
||||||
'event: new_mail\ndata: {"n":1}\n\n' +
|
'event: new_mail\ndata: {"n":1}\n\n' +
|
||||||
'event: new_mail\ndata: {"n":2}\n\n' +
|
'event: new_mail\ndata: {"n":2}\n\n' +
|
||||||
'event: session_update\ndata: {"n":3}\n\n',
|
'event: session_update\ndata: {"n":3}\n\n'
|
||||||
);
|
);
|
||||||
assert.equal(events.length, 3);
|
assert.equal(events.length, 3);
|
||||||
assert.deepEqual(
|
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']);
|
||||||
events.map((e) => e.event),
|
|
||||||
["new_mail", "new_mail", "session_update"],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("注释/心跳行被忽略,不影响后续帧", () => {
|
test('注释/心跳行被忽略,不影响后续帧', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("多行 data 用换行拼接", () => {
|
test('多行 data 用换行拼接', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
|
const events = p.push('event: x\ndata: line1\ndata: line2\n\n');
|
||||||
assert.equal(events[0].data, "line1\nline2");
|
assert.equal(events[0].data, 'line1\nline2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CRLF 不被当成事件名或 JSON 的一部分", () => {
|
test('CRLF 不被当成事件名或 JSON 的一部分', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
assert.equal(events[0].id, "3");
|
assert.equal(events[0].id, '3');
|
||||||
assert.deepEqual(parse(events[0].data), { n: 1 });
|
assert.deepEqual(parse(events[0].data), { n: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
|
test('事件 id 只向前推进:重放旧 id 不回退断点', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "10");
|
assert.equal(p.lastEventId(), '10');
|
||||||
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
||||||
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
||||||
assert.equal(
|
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id(是否回退由使用方决定)');
|
||||||
p.lastEventId(),
|
|
||||||
"5",
|
|
||||||
"解析器如实记录当前 id(是否回退由使用方决定)",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("id 在派发前记录:回调抛异常也不丢断点", () => {
|
test('id 在派发前记录:回调抛异常也不丢断点', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "42");
|
assert.equal(p.lastEventId(), '42');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
|
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('data: {"orphan":true}\n\n');
|
const events = p.push('data: {"orphan":true}\n\n');
|
||||||
assert.deepEqual(events, []);
|
assert.deepEqual(events, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
|
test('reset 清缓冲但保留断点(重连后仍能续传)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
||||||
p.push("event: partial"); // 半帧
|
p.push('event: partial'); // 半帧
|
||||||
p.reset();
|
p.reset();
|
||||||
assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
|
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放');
|
||||||
// reset 后半帧不该复活
|
// reset 后半帧不该复活
|
||||||
const after = p.push('data: {"n":2}\n\n');
|
const after = p.push('data: {"n":2}\n\n');
|
||||||
assert.deepEqual(after, []);
|
assert.deepEqual(after, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
|
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "123");
|
assert.equal(p.lastEventId(), '123');
|
||||||
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
||||||
p.setLastEventId("");
|
p.setLastEventId('');
|
||||||
assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
|
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
* - 空回答不能被当成「答了」
|
* - 空回答不能被当成「答了」
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from 'node:test';
|
||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
flattenQuestions,
|
flattenQuestions,
|
||||||
@ -20,169 +20,117 @@ import {
|
|||||||
hasOptions,
|
hasOptions,
|
||||||
optionLabels,
|
optionLabels,
|
||||||
questionTitle,
|
questionTitle,
|
||||||
} from "../lib/user-question.js";
|
} from '../lib/user-question.js';
|
||||||
|
|
||||||
test("单问题单选项:忠实映射问题与选项", () => {
|
test('单问题单选项:忠实映射问题与选项', () => {
|
||||||
const flat = flattenQuestions([
|
const flat = flattenQuestions([{
|
||||||
{
|
id: 'q1',
|
||||||
id: "q1",
|
question: '用哪种方案?',
|
||||||
question: "用哪种方案?",
|
header: '选择',
|
||||||
header: "选择",
|
options: [{ label: '方案 A' }, { label: '方案 B' }],
|
||||||
options: [{ label: "方案 A" }, { label: "方案 B" }],
|
}]);
|
||||||
},
|
assert.equal(flat.question, '选择: 用哪种方案?');
|
||||||
]);
|
assert.deepEqual(flat.options, ['方案 A', '方案 B']);
|
||||||
assert.equal(flat.question, "选择: 用哪种方案?");
|
|
||||||
assert.deepEqual(flat.options, ["方案 A", "方案 B"]);
|
|
||||||
assert.equal(flat.multiSelect, false);
|
assert.equal(flat.multiSelect, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("单问题多选:multiSelect 透传", () => {
|
test('单问题多选:multiSelect 透传', () => {
|
||||||
const flat = flattenQuestions([
|
const flat = flattenQuestions([{
|
||||||
{
|
id: 'q1',
|
||||||
id: "q1",
|
question: '要哪些?',
|
||||||
question: "要哪些?",
|
options: [{ label: 'a' }, { label: 'b' }],
|
||||||
options: [{ label: "a" }, { label: "b" }],
|
|
||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
},
|
}]);
|
||||||
]);
|
|
||||||
assert.equal(flat.multiSelect, true);
|
assert.equal(flat.multiSelect, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("多问题:选项取并集且去重保序,multiSelect 置真", () => {
|
test('多问题:选项取并集且去重保序,multiSelect 置真', () => {
|
||||||
const flat = flattenQuestions([
|
const flat = flattenQuestions([
|
||||||
{
|
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
|
||||||
id: "q1",
|
{ id: 'q2', question: '后端?', options: [{ label: 'Vue' }, { label: 'Go' }] },
|
||||||
question: "前端?",
|
|
||||||
options: [{ label: "React" }, { label: "Vue" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q2",
|
|
||||||
question: "后端?",
|
|
||||||
options: [{ label: "Vue" }, { label: "Go" }],
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(flat.options, ['React', 'Vue', 'Go'], '重复 label 只出现一次');
|
||||||
flat.options,
|
assert.equal(flat.multiSelect, true, '多问题必须允许多选,否则无法同时回答两题');
|
||||||
["React", "Vue", "Go"],
|
|
||||||
"重复 label 只出现一次",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
flat.multiSelect,
|
|
||||||
true,
|
|
||||||
"多问题必须允许多选,否则无法同时回答两题",
|
|
||||||
);
|
|
||||||
assert.match(flat.context, /前端?/);
|
assert.match(flat.context, /前端?/);
|
||||||
assert.match(flat.context, /后端?/);
|
assert.match(flat.context, /后端?/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("无选项题:options 为空,正文提示直接填写", () => {
|
test('无选项题:options 为空,正文提示直接填写', () => {
|
||||||
const flat = flattenQuestions([{ id: "q1", question: "你的名字?" }]);
|
const flat = flattenQuestions([{ id: 'q1', question: '你的名字?' }]);
|
||||||
assert.deepEqual(flat.options, []);
|
assert.deepEqual(flat.options, []);
|
||||||
assert.match(flat.context, /直接填写|没有预设选项/);
|
assert.match(flat.context, /直接填写|没有预设选项/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("空问题列表:抛错而不是造一封没有内容的信", () => {
|
test('空问题列表:抛错而不是造一封没有内容的信', () => {
|
||||||
assert.throws(() => flattenQuestions([]), /至少需要一个/);
|
assert.throws(() => flattenQuestions([]), /至少需要一个/);
|
||||||
assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
|
assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("单问题回写:选项进 selected,备注进 custom", () => {
|
test('单问题回写:选项进 selected,备注进 custom', () => {
|
||||||
|
const qs = [{ id: 'q1', question: '选哪个', options: [{ label: 'A' }, { label: 'B' }] }];
|
||||||
|
const ans = answersFromDecision(qs, 'A', '再确认下');
|
||||||
|
assert.deepEqual(ans.answers, [{ id: 'q1', selected: ['A'], custom: '再确认下' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单问题多选回写:多行决策拆成多个 selected', () => {
|
||||||
|
const qs = [{ id: 'q1', question: '选哪些', options: [{ label: 'A' }, { label: 'B' }], multiSelect: true }];
|
||||||
|
const ans = answersFromDecision(qs, 'A\nB', '');
|
||||||
|
assert.deepEqual(ans.answers[0].selected, ['A', 'B']);
|
||||||
|
assert.equal(ans.answers[0].custom, undefined, '空备注不该变成空 custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无选项题回写:答案进 custom,selected 为空', () => {
|
||||||
|
const qs = [{ id: 'q1', question: '名字?' }];
|
||||||
|
const ans = answersFromDecision(qs, '', '张三');
|
||||||
|
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '张三' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无选项题只有决策文本时:文本进 custom(否则答案永远为空)', () => {
|
||||||
|
const qs = [{ id: 'q1', question: '名字?' }];
|
||||||
|
const ans = answersFromDecision(qs, '李四', undefined);
|
||||||
|
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '李四' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)', () => {
|
||||||
const qs = [
|
const qs = [
|
||||||
{ id: "q1", question: "选哪个", options: [{ label: "A" }, { label: "B" }] },
|
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
|
||||||
|
{ id: 'q2', question: '后端?', options: [{ label: 'Go' }, { label: 'Rust' }] },
|
||||||
];
|
];
|
||||||
const ans = answersFromDecision(qs, "A", "再确认下");
|
const ans = answersFromDecision(qs, 'Vue\nGo', '都行');
|
||||||
assert.deepEqual(ans.answers, [
|
assert.deepEqual(ans.answers[0].selected, ['Vue'], 'q1 只拿前端的选择');
|
||||||
{ id: "q1", selected: ["A"], custom: "再确认下" },
|
assert.deepEqual(ans.answers[1].selected, ['Go'], 'q2 只拿后端的选择');
|
||||||
]);
|
assert.equal(ans.answers[0].custom, '都行', '备注归第一个问题');
|
||||||
|
assert.equal(ans.answers[1].custom, undefined, '备注不重复分发');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("单问题多选回写:多行决策拆成多个 selected", () => {
|
test('多问题里认不出的 label:不匹配任何问题,不猜测放行', () => {
|
||||||
const qs = [
|
const qs = [
|
||||||
{
|
{ id: 'q1', question: 'a', options: [{ label: 'X' }] },
|
||||||
id: "q1",
|
{ id: 'q2', question: 'b', options: [{ label: 'Y' }] },
|
||||||
question: "选哪些",
|
|
||||||
options: [{ label: "A" }, { label: "B" }],
|
|
||||||
multiSelect: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
const ans = answersFromDecision(qs, "A\nB", "");
|
const ans = answersFromDecision(qs, 'Z', '');
|
||||||
assert.deepEqual(ans.answers[0].selected, ["A", "B"]);
|
assert.deepEqual(ans.answers[0].selected, [], '认不出的 label 不得被塞进任意问题');
|
||||||
assert.equal(ans.answers[0].custom, undefined, "空备注不该变成空 custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("无选项题回写:答案进 custom,selected 为空", () => {
|
|
||||||
const qs = [{ id: "q1", question: "名字?" }];
|
|
||||||
const ans = answersFromDecision(qs, "", "张三");
|
|
||||||
assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "张三" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("无选项题只有决策文本时:文本进 custom(否则答案永远为空)", () => {
|
|
||||||
const qs = [{ id: "q1", question: "名字?" }];
|
|
||||||
const ans = answersFromDecision(qs, "李四", undefined);
|
|
||||||
assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "李四" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)", () => {
|
|
||||||
const qs = [
|
|
||||||
{
|
|
||||||
id: "q1",
|
|
||||||
question: "前端?",
|
|
||||||
options: [{ label: "React" }, { label: "Vue" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q2",
|
|
||||||
question: "后端?",
|
|
||||||
options: [{ label: "Go" }, { label: "Rust" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const ans = answersFromDecision(qs, "Vue\nGo", "都行");
|
|
||||||
assert.deepEqual(ans.answers[0].selected, ["Vue"], "q1 只拿前端的选择");
|
|
||||||
assert.deepEqual(ans.answers[1].selected, ["Go"], "q2 只拿后端的选择");
|
|
||||||
assert.equal(ans.answers[0].custom, "都行", "备注归第一个问题");
|
|
||||||
assert.equal(ans.answers[1].custom, undefined, "备注不重复分发");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("多问题里认不出的 label:不匹配任何问题,不猜测放行", () => {
|
|
||||||
const qs = [
|
|
||||||
{ id: "q1", question: "a", options: [{ label: "X" }] },
|
|
||||||
{ id: "q2", question: "b", options: [{ label: "Y" }] },
|
|
||||||
];
|
|
||||||
const ans = answersFromDecision(qs, "Z", "");
|
|
||||||
assert.deepEqual(
|
|
||||||
ans.answers[0].selected,
|
|
||||||
[],
|
|
||||||
"认不出的 label 不得被塞进任意问题",
|
|
||||||
);
|
|
||||||
assert.deepEqual(ans.answers[1].selected, []);
|
assert.deepEqual(ans.answers[1].selected, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("answers 的 id 与问题一一对应", () => {
|
test('answers 的 id 与问题一一对应', () => {
|
||||||
const qs = [
|
const qs = [{ id: 'alpha', question: 'a', options: [{ label: 'X' }] }, { id: 'beta', question: 'b' }];
|
||||||
{ id: "alpha", question: "a", options: [{ label: "X" }] },
|
const ans = answersFromDecision(qs, 'X', 'note');
|
||||||
{ id: "beta", question: "b" },
|
assert.deepEqual(ans.answers.map((a) => a.id), ['alpha', 'beta']);
|
||||||
];
|
|
||||||
const ans = answersFromDecision(qs, "X", "note");
|
|
||||||
assert.deepEqual(
|
|
||||||
ans.answers.map((a) => a.id),
|
|
||||||
["alpha", "beta"],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("空回答判定:有选项的问题什么都没选 = 空", () => {
|
test('空回答判定:有选项的问题什么都没选 = 空', () => {
|
||||||
const qs = [{ id: "q1", question: "a", options: [{ label: "X" }] }];
|
const qs = [{ id: 'q1', question: 'a', options: [{ label: 'X' }] }];
|
||||||
assert.equal(isBlankAnswer(qs, "", ""), true);
|
assert.equal(isBlankAnswer(qs, '', ''), true);
|
||||||
assert.equal(isBlankAnswer(qs, "X", ""), false);
|
assert.equal(isBlankAnswer(qs, 'X', ''), false);
|
||||||
assert.equal(isBlankAnswer(qs, "", "自由文本"), false, "自由文本也算答了");
|
assert.equal(isBlankAnswer(qs, '', '自由文本'), false, '自由文本也算答了');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hasOptions / optionLabels / questionTitle 的边界", () => {
|
test('hasOptions / optionLabels / questionTitle 的边界', () => {
|
||||||
assert.equal(hasOptions({}), false);
|
assert.equal(hasOptions({}), false);
|
||||||
assert.equal(hasOptions({ options: [] }), false);
|
assert.equal(hasOptions({ options: [] }), false);
|
||||||
assert.equal(hasOptions({ options: [{ label: "a" }] }), true);
|
assert.equal(hasOptions({ options: [{ label: 'a' }] }), true);
|
||||||
assert.deepEqual(
|
assert.deepEqual(optionLabels({ options: ['a', { label: 'b' }, { description: 'x' }] }), ['a', 'b']);
|
||||||
optionLabels({ options: ["a", { label: "b" }, { description: "x" }] }),
|
assert.equal(questionTitle({ question: '只问一句' }), '只问一句');
|
||||||
["a", "b"],
|
assert.equal(questionTitle({}), '(未提供问题)');
|
||||||
);
|
|
||||||
assert.equal(questionTitle({ question: "只问一句" }), "只问一句");
|
|
||||||
assert.equal(questionTitle({}), "(未提供问题)");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,17 +5,16 @@
|
|||||||
* 本文件只管认证头、密钥解析与坐标变更。
|
* 本文件只管认证头、密钥解析与坐标变更。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from 'node:crypto';
|
||||||
import { homedir } from "node:os";
|
import { homedir } from 'node:os';
|
||||||
import { join } from "node:path";
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { createSSEClient } from "../lib/sse-client.js";
|
import { createSSEClient } from '../lib/sse-client.js';
|
||||||
|
|
||||||
const CONFIG_DIR =
|
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||||||
process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail");
|
export const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||||||
export const KEY_FILE = join(CONFIG_DIR, "agent.key");
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把管理员给的密钥落盘(0600)。
|
* 把管理员给的密钥落盘(0600)。
|
||||||
@ -27,11 +26,7 @@ export function saveLocalKey(token) {
|
|||||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
KEY_FILE,
|
KEY_FILE,
|
||||||
JSON.stringify(
|
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||||
{ key_token: token, created_at: new Date().toISOString() },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
{ mode: 0o600 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -40,10 +35,8 @@ export function saveLocalKey(token) {
|
|||||||
export function readLocalKey() {
|
export function readLocalKey() {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(KEY_FILE)) return null;
|
if (!existsSync(KEY_FILE)) return null;
|
||||||
const raw = JSON.parse(readFileSync(KEY_FILE, "utf8"));
|
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
|
||||||
return typeof raw?.key_token === "string" && raw.key_token
|
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null;
|
||||||
? raw.key_token
|
|
||||||
: null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -56,15 +49,11 @@ export function readLocalKey() {
|
|||||||
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。
|
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。
|
||||||
*/
|
*/
|
||||||
export function generateLocalKey(log = console.error) {
|
export function generateLocalKey(log = console.error) {
|
||||||
const token = randomBytes(32).toString("hex");
|
const token = randomBytes(32).toString('hex');
|
||||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
KEY_FILE,
|
KEY_FILE,
|
||||||
JSON.stringify(
|
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||||
{ key_token: token, created_at: new Date().toISOString() },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
{ mode: 0o600 },
|
||||||
);
|
);
|
||||||
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
|
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
|
||||||
@ -80,17 +69,11 @@ export function saveConfig(extra) {
|
|||||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
let cur = {};
|
let cur = {};
|
||||||
if (existsSync(CONFIG_FILE)) {
|
if (existsSync(CONFIG_FILE)) {
|
||||||
try {
|
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ }
|
||||||
cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
||||||
} catch {
|
|
||||||
/* 损坏就重写 */
|
|
||||||
}
|
}
|
||||||
}
|
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 });
|
||||||
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[pi-mail-bridge] 写 config.json 失败:", e?.message || e);
|
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,46 +82,36 @@ export class GatewayClient {
|
|||||||
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
|
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
|
||||||
*/
|
*/
|
||||||
constructor({ url, agentName, agentKey, agentSecret }) {
|
constructor({ url, agentName, agentKey, agentSecret }) {
|
||||||
this.baseURL = String(url || "http://127.0.0.1:8180").replace(/\/+$/, "");
|
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, '');
|
||||||
this.agentName = agentName;
|
this.agentName = agentName;
|
||||||
this.agentKey = agentKey || "";
|
this.agentKey = agentKey || '';
|
||||||
this.agentSecret = agentSecret || "";
|
this.agentSecret = agentSecret || '';
|
||||||
this.sseClient = null;
|
this.sseClient = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
||||||
authHeaders() {
|
authHeaders() {
|
||||||
if (this.agentKey) {
|
if (this.agentKey) {
|
||||||
return {
|
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
|
||||||
Authorization: `Bearer ${this.agentKey}`,
|
|
||||||
"X-Agent-Name": this.agentName,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
|
||||||
"X-Agent-Name": this.agentName,
|
|
||||||
"X-Agent-Secret": this.agentSecret,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(path) {
|
async get(path) {
|
||||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
|
||||||
headers: this.authHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async post(path, body) {
|
async post(path, body) {
|
||||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json", ...this.authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(
|
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
|
||||||
data?.error || `POST ${path} 失败: HTTP ${res.status}`,
|
|
||||||
);
|
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
// 响应体也带上:服务端对 409 会给 detail/suggestion,
|
// 响应体也带上:服务端对 409 会给 detail/suggestion,
|
||||||
// 那些文字要原文转给模型(它据此决定换什么做法)。
|
// 那些文字要原文转给模型(它据此决定换什么做法)。
|
||||||
@ -150,11 +123,11 @@ export class GatewayClient {
|
|||||||
|
|
||||||
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
|
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
|
||||||
async register() {
|
async register() {
|
||||||
return this.post("/agent/register", {
|
return this.post('/agent/register', {
|
||||||
name: this.agentName,
|
name: this.agentName,
|
||||||
secret: this.agentSecret || "",
|
secret: this.agentSecret || '',
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
platform: "pi",
|
platform: 'pi',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,9 +142,9 @@ export class GatewayClient {
|
|||||||
*/
|
*/
|
||||||
async uploadFile(buf, filename) {
|
async uploadFile(buf, filename) {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", new Blob([buf]), filename);
|
form.append('file', new Blob([buf]), filename);
|
||||||
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
|
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: this.authHeaders(),
|
headers: this.authHeaders(),
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
@ -181,12 +154,9 @@ export class GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile(attachmentID) {
|
async downloadFile(attachmentID) {
|
||||||
const res = await fetch(
|
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
|
||||||
`${this.baseURL}/api/v1/attachments/${attachmentID}`,
|
|
||||||
{
|
|
||||||
headers: this.authHeaders(),
|
headers: this.authHeaders(),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||||||
return Buffer.from(await res.arrayBuffer());
|
return Buffer.from(await res.arrayBuffer());
|
||||||
}
|
}
|
||||||
@ -207,7 +177,7 @@ export class GatewayClient {
|
|||||||
this.sseClient = createSSEClient({
|
this.sseClient = createSSEClient({
|
||||||
authHeaders: () => this.authHeaders(),
|
authHeaders: () => this.authHeaders(),
|
||||||
baseURL: this.baseURL,
|
baseURL: this.baseURL,
|
||||||
path: "/api/v1/events/stream",
|
path: '/api/v1/events/stream',
|
||||||
onEvent,
|
onEvent,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
@ -224,7 +194,7 @@ export class GatewayClient {
|
|||||||
* @returns {boolean} 是否真的变了(没变就不必重连 SSE,省一次断流)
|
* @returns {boolean} 是否真的变了(没变就不必重连 SSE,省一次断流)
|
||||||
*/
|
*/
|
||||||
reconfigure({ url, agentKey }) {
|
reconfigure({ url, agentKey }) {
|
||||||
const nextURL = url ? String(url).replace(/\/+$/, "") : this.baseURL;
|
const nextURL = url ? String(url).replace(/\/+$/, '') : this.baseURL;
|
||||||
const nextKey = agentKey || this.agentKey;
|
const nextKey = agentKey || this.agentKey;
|
||||||
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
|
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
|
||||||
if (!changed) return false;
|
if (!changed) return false;
|
||||||
|
|||||||
@ -56,16 +56,12 @@
|
|||||||
* 一处只增不减的结构。
|
* 一处只增不减的结构。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fork } from "node:child_process";
|
import { fork } from 'node:child_process';
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import {
|
import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||||
BoundedMap,
|
|
||||||
BoundedSet,
|
|
||||||
MAX_TRACKED_SESSIONS,
|
|
||||||
} from "../lib/bounded.js";
|
|
||||||
|
|
||||||
const WORKER_PATH = fileURLToPath(new URL("./worker.mjs", import.meta.url));
|
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} deps
|
* @param {object} deps
|
||||||
@ -81,13 +77,8 @@ const WORKER_PATH = fileURLToPath(new URL("./worker.mjs", import.meta.url));
|
|||||||
* 让调度不变量(并发上限、同会话串行、硬超时)能在毫秒级验证。
|
* 让调度不变量(并发上限、同会话串行、硬超时)能在毫秒级验证。
|
||||||
*/
|
*/
|
||||||
export function createWorkerPool({
|
export function createWorkerPool({
|
||||||
log,
|
log, config, onReconfigure,
|
||||||
config,
|
maxWorkers = 3, workerMaxMs = 600_000, maxAttempts = 3, workerPath = WORKER_PATH,
|
||||||
onReconfigure,
|
|
||||||
maxWorkers = 3,
|
|
||||||
workerMaxMs = 600_000,
|
|
||||||
maxAttempts = 3,
|
|
||||||
workerPath = WORKER_PATH,
|
|
||||||
}) {
|
}) {
|
||||||
/** 正在跑的 worker:mailSessionKey -> {child, mailID, startedAt, timer} */
|
/** 正在跑的 worker:mailSessionKey -> {child, mailID, startedAt, timer} */
|
||||||
const running = new Map();
|
const running = new Map();
|
||||||
@ -118,8 +109,7 @@ export function createWorkerPool({
|
|||||||
* 没有 session_id 的事件(理论上不该有)退回 mail_id:那样每封各占一个
|
* 没有 session_id 的事件(理论上不该有)退回 mail_id:那样每封各占一个
|
||||||
* worker,不会串行 —— 但它们本来也不属于同一条会话。
|
* worker,不会串行 —— 但它们本来也不属于同一条会话。
|
||||||
*/
|
*/
|
||||||
const keyOf = (data) =>
|
const keyOf = (data) => data?.session_id || `mail:${data?.mail_id || Math.random()}`;
|
||||||
data?.session_id || `mail:${data?.mail_id || Math.random()}`;
|
|
||||||
|
|
||||||
function submit(kind, data, attempt = 1) {
|
function submit(kind, data, attempt = 1) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
@ -142,47 +132,33 @@ export function createWorkerPool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spawn(job) {
|
function spawn(job) {
|
||||||
const state = sessionState.get(job.key) || {
|
const state = sessionState.get(job.key) || { grants: new Set(), lastSyncedName: '' };
|
||||||
grants: new Set(),
|
|
||||||
lastSyncedName: "",
|
|
||||||
};
|
|
||||||
const child = fork(workerPath, [], {
|
const child = fork(workerPath, [], {
|
||||||
// stdio 继承:worker 里 pi SDK 自己打的东西直接进 journalctl。
|
// stdio 继承:worker 里 pi SDK 自己打的东西直接进 journalctl。
|
||||||
// 'ipc' 必须显式列出,否则 process.send 不存在。
|
// 'ipc' 必须显式列出,否则 process.send 不存在。
|
||||||
stdio: ["ignore", "inherit", "inherit", "ipc"],
|
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 硬超时:worker 卡死(模型不返回、权限等不到决策而主进程也没收到事件)
|
// 硬超时:worker 卡死(模型不返回、权限等不到决策而主进程也没收到事件)
|
||||||
// 时必须能回收,否则那条会话的后续邮件永远排队。
|
// 时必须能回收,否则那条会话的后续邮件永远排队。
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
log(
|
log(`worker ${child.pid} 处理 ${job.data?.mail_id} 超过 ${workerMaxMs / 1000}s,强杀`);
|
||||||
`worker ${child.pid} 处理 ${job.data?.mail_id} 超过 ${workerMaxMs / 1000}s,强杀`,
|
try { child.kill('SIGKILL'); } catch { /* 已经死了 */ }
|
||||||
);
|
|
||||||
try {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
} catch {
|
|
||||||
/* 已经死了 */
|
|
||||||
}
|
|
||||||
}, workerMaxMs);
|
}, workerMaxMs);
|
||||||
if (typeof timer.unref === "function") timer.unref();
|
if (typeof timer.unref === 'function') timer.unref();
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
child,
|
child, mailID: job.data?.mail_id || '', key: job.key,
|
||||||
mailID: job.data?.mail_id || "",
|
startedAt: Date.now(), timer, settled: false,
|
||||||
key: job.key,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
timer,
|
|
||||||
settled: false,
|
|
||||||
};
|
};
|
||||||
running.set(job.key, entry);
|
running.set(job.key, entry);
|
||||||
|
|
||||||
child.on("message", (msg) => onWorkerMessage(entry, msg));
|
child.on('message', (msg) => onWorkerMessage(entry, msg));
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
running.delete(job.key);
|
running.delete(job.key);
|
||||||
for (const [rk, k] of permissionRoutes)
|
for (const [rk, k] of permissionRoutes) if (k === job.key) permissionRoutes.delete(rk);
|
||||||
if (k === job.key) permissionRoutes.delete(rk);
|
|
||||||
|
|
||||||
// 没收到 `done` 就退出 = 这封邮件**从未处理完**。
|
// 没收到 `done` 就退出 = 这封邮件**从未处理完**。
|
||||||
//
|
//
|
||||||
@ -198,65 +174,54 @@ export function createWorkerPool({
|
|||||||
const attempt = job.attempt || 1;
|
const attempt = job.attempt || 1;
|
||||||
if (attempt < maxAttempts) {
|
if (attempt < maxAttempts) {
|
||||||
const delay = attempt * 1000;
|
const delay = attempt * 1000;
|
||||||
log(
|
log(`worker ${child.pid}(mail ${entry.mailID})未回报 done 就退出`
|
||||||
`worker ${child.pid}(mail ${entry.mailID})未回报 done 就退出` +
|
+ `(code=${code} signal=${signal || '-'}),${delay / 1000}s 后`
|
||||||
`(code=${code} signal=${signal || "-"}),${delay / 1000}s 后` +
|
+ `第 ${attempt + 1}/${maxAttempts} 次重投`);
|
||||||
`第 ${attempt + 1}/${maxAttempts} 次重投`,
|
|
||||||
);
|
|
||||||
const retry = setTimeout(() => {
|
const retry = setTimeout(() => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
queue.push({ ...job, attempt: attempt + 1 });
|
queue.push({ ...job, attempt: attempt + 1 });
|
||||||
pump();
|
pump();
|
||||||
}, delay);
|
}, delay);
|
||||||
if (typeof retry.unref === "function") retry.unref();
|
if (typeof retry.unref === 'function') retry.unref();
|
||||||
// 退避期间不 pump:否则同一会话会被立刻重投,退避形同虚设
|
// 退避期间不 pump:否则同一会话会被立刻重投,退避形同虚设
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log(
|
log(`worker ${child.pid}(mail ${entry.mailID})重投 ${maxAttempts} 次仍未完成,放弃`
|
||||||
`worker ${child.pid}(mail ${entry.mailID})重投 ${maxAttempts} 次仍未完成,放弃` +
|
+ `(code=${code} signal=${signal || '-'})`);
|
||||||
`(code=${code} signal=${signal || "-"})`,
|
|
||||||
);
|
|
||||||
} else if (code !== 0) {
|
} else if (code !== 0) {
|
||||||
log(
|
log(`worker ${child.pid}(mail ${entry.mailID})异常退出 code=${code} signal=${signal || '-'}`);
|
||||||
`worker ${child.pid}(mail ${entry.mailID})异常退出 code=${code} signal=${signal || "-"}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
pump();
|
pump();
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("error", (e) =>
|
child.on('error', (e) => log(`worker ${child.pid} 出错: ${e?.message || e}`));
|
||||||
log(`worker ${child.pid} 出错: ${e?.message || e}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 等 worker 说 ready 再派活:fork 返回时子进程的 import 还没跑完,
|
// 等 worker 说 ready 再派活:fork 返回时子进程的 import 还没跑完,
|
||||||
// 此时 send 的消息会排在 IPC 队列里(能收到,但 ready 让顺序确定)。
|
// 此时 send 的消息会排在 IPC 队列里(能收到,但 ready 让顺序确定)。
|
||||||
child.once("message", function first(msg) {
|
child.once('message', function first(msg) {
|
||||||
if (msg?.type !== "ready") return;
|
if (msg?.type !== 'ready') return;
|
||||||
child.send({
|
child.send({
|
||||||
type: "job",
|
type: 'job',
|
||||||
kind: job.kind,
|
kind: job.kind,
|
||||||
data: job.data,
|
data: job.data,
|
||||||
session: {
|
session: {
|
||||||
sessionFile: state.sessionFile || "",
|
sessionFile: state.sessionFile || '',
|
||||||
cwd: state.cwd || "",
|
cwd: state.cwd || '',
|
||||||
},
|
},
|
||||||
grants: [...state.grants],
|
grants: [...state.grants],
|
||||||
lastSyncedName: state.lastSyncedName || "",
|
lastSyncedName: state.lastSyncedName || '',
|
||||||
config: config(),
|
config: config(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWorkerMessage(entry, msg) {
|
function onWorkerMessage(entry, msg) {
|
||||||
const state = sessionState.get(entry.key) || {
|
const state = sessionState.get(entry.key) || { grants: new Set(), lastSyncedName: '' };
|
||||||
grants: new Set(),
|
|
||||||
lastSyncedName: "",
|
|
||||||
};
|
|
||||||
switch (msg?.type) {
|
switch (msg?.type) {
|
||||||
case "log":
|
case 'log':
|
||||||
log(`[w${entry.child.pid}] ${msg.line}`);
|
log(`[w${entry.child.pid}] ${msg.line}`);
|
||||||
return;
|
return;
|
||||||
case "session_opened":
|
case 'session_opened':
|
||||||
// 一条会话可能先后用过多个 pi 会话 id(模型降级会换会话)。
|
// 一条会话可能先后用过多个 pi 会话 id(模型降级会换会话)。
|
||||||
// 旧 id 仍计入 mail_driven,理由见 retired 的注释。
|
// 旧 id 仍计入 mail_driven,理由见 retired 的注释。
|
||||||
if (state.piSessionId && state.piSessionId !== msg.piSessionId) {
|
if (state.piSessionId && state.piSessionId !== msg.piSessionId) {
|
||||||
@ -267,23 +232,23 @@ export function createWorkerPool({
|
|||||||
state.cwd = msg.cwd;
|
state.cwd = msg.cwd;
|
||||||
sessionState.set(entry.key, state);
|
sessionState.set(entry.key, state);
|
||||||
return;
|
return;
|
||||||
case "permission_pending":
|
case 'permission_pending':
|
||||||
permissionRoutes.set(msg.relayKey, entry.key);
|
permissionRoutes.set(msg.relayKey, entry.key);
|
||||||
return;
|
return;
|
||||||
case "permission_grant":
|
case 'permission_grant':
|
||||||
// 「一直同意」必须跨 worker 活着:worker 一封一进程,不存的话下一封
|
// 「一直同意」必须跨 worker 活着:worker 一封一进程,不存的话下一封
|
||||||
// 邮件又问一遍,那个选项就是在骗人。
|
// 邮件又问一遍,那个选项就是在骗人。
|
||||||
state.grants.add(msg.toolName);
|
state.grants.add(msg.toolName);
|
||||||
sessionState.set(entry.key, state);
|
sessionState.set(entry.key, state);
|
||||||
return;
|
return;
|
||||||
case "name_synced":
|
case 'name_synced':
|
||||||
state.lastSyncedName = msg.signature;
|
state.lastSyncedName = msg.signature;
|
||||||
sessionState.set(entry.key, state);
|
sessionState.set(entry.key, state);
|
||||||
return;
|
return;
|
||||||
case "reconfigure":
|
case 'reconfigure':
|
||||||
onReconfigure?.(msg.url, msg.agentKey);
|
onReconfigure?.(msg.url, msg.agentKey);
|
||||||
return;
|
return;
|
||||||
case "done":
|
case 'done':
|
||||||
// 标记「这封真的处理完了」:exit 处理器据此区分「正常收尾」
|
// 标记「这封真的处理完了」:exit 处理器据此区分「正常收尾」
|
||||||
// 与「未回报就崩溃」(后者要重投)。
|
// 与「未回报就崩溃」(后者要重投)。
|
||||||
entry.settled = true;
|
entry.settled = true;
|
||||||
@ -310,7 +275,7 @@ export function createWorkerPool({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
permissionRoutes.delete(relayKey);
|
permissionRoutes.delete(relayKey);
|
||||||
entry.child.send({ type: "permission_decision", relayKey, decision });
|
entry.child.send({ type: 'permission_decision', relayKey, decision });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,18 +331,8 @@ export function createWorkerPool({
|
|||||||
// 先 shutdown 让 worker 把未决权限 fail closed(B-9.2),再给它一点
|
// 先 shutdown 让 worker 把未决权限 fail closed(B-9.2),再给它一点
|
||||||
// 时间自己退。不直接 SIGKILL:那样 pi 侧的 await 不会返回,而 worker
|
// 时间自己退。不直接 SIGKILL:那样 pi 侧的 await 不会返回,而 worker
|
||||||
// 里可能正握着会话文件。
|
// 里可能正握着会话文件。
|
||||||
try {
|
try { child.send({ type: 'shutdown' }); } catch { /* 通道已断 */ }
|
||||||
child.send({ type: "shutdown" });
|
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* 已经死了 */ } }, 2000).unref?.();
|
||||||
} catch {
|
|
||||||
/* 通道已断 */
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
} catch {
|
|
||||||
/* 已经死了 */
|
|
||||||
}
|
|
||||||
}, 2000).unref?.();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -390,19 +345,9 @@ export function createWorkerPool({
|
|||||||
// 而症状是「这条会话怎么突然不记得前面说过什么了」。
|
// 而症状是「这条会话怎么突然不记得前面说过什么了」。
|
||||||
evictedSessions: sessionState.evicted,
|
evictedSessions: sessionState.evicted,
|
||||||
workers: [...running.values()].map((e) => ({
|
workers: [...running.values()].map((e) => ({
|
||||||
pid: e.child.pid,
|
pid: e.child.pid, mailID: e.mailID, ageMs: Date.now() - e.startedAt,
|
||||||
mailID: e.mailID,
|
|
||||||
ageMs: Date.now() - e.startedAt,
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { submit, routePermission, hasSession, forget, mailDrivenIDs, stop, stats };
|
||||||
submit,
|
|
||||||
routePermission,
|
|
||||||
hasSession,
|
|
||||||
forget,
|
|
||||||
mailDrivenIDs,
|
|
||||||
stop,
|
|
||||||
stats,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,21 +11,19 @@
|
|||||||
* 而不是一个间接的计数(计数在字段被丢掉时依然会给出绿色)。
|
* 而不是一个间接的计数(计数在字段被丢掉时依然会给出绿色)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from 'node:test';
|
||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from "node:path";
|
import { join } from 'node:path';
|
||||||
|
|
||||||
// ─── 桩 worker ───
|
// ─── 桩 worker ───
|
||||||
//
|
//
|
||||||
// 落到临时目录而不是仓库里:它是测试脚手架,不该被 check-shared-libs 之类的
|
// 落到临时目录而不是仓库里:它是测试脚手架,不该被 check-shared-libs 之类的
|
||||||
// 一致性脚本看到,也不该让人误以为是第二个真 worker。
|
// 一致性脚本看到,也不该让人误以为是第二个真 worker。
|
||||||
const STUB_DIR = mkdtempSync(join(tmpdir(), "pi-pool-test-"));
|
const STUB_DIR = mkdtempSync(join(tmpdir(), 'pi-pool-test-'));
|
||||||
const STUB = join(STUB_DIR, "stub-worker.mjs");
|
const STUB = join(STUB_DIR, 'stub-worker.mjs');
|
||||||
writeFileSync(
|
writeFileSync(STUB, `
|
||||||
STUB,
|
|
||||||
`
|
|
||||||
process.on('message', (msg) => {
|
process.on('message', (msg) => {
|
||||||
if (msg?.type === 'job') {
|
if (msg?.type === 'job') {
|
||||||
const hold = msg.data?.__hold ?? 30;
|
const hold = msg.data?.__hold ?? 30;
|
||||||
@ -82,16 +80,15 @@ process.on('message', (msg) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
process.send({ type: 'ready' });
|
process.send({ type: 'ready' });
|
||||||
`,
|
`);
|
||||||
);
|
|
||||||
|
|
||||||
const { createWorkerPool } = await import("../src/pool.mjs");
|
const { createWorkerPool } = await import('../src/pool.mjs');
|
||||||
|
|
||||||
/** 建一个用桩 worker 的池。 */
|
/** 建一个用桩 worker 的池。 */
|
||||||
function makePool(opts = {}) {
|
function makePool(opts = {}) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
const pool = createWorkerPool({
|
const pool = createWorkerPool({
|
||||||
log: (...a) => lines.push(a.join(" ")),
|
log: (...a) => lines.push(a.join(' ')),
|
||||||
config: () => ({ turnTimeoutMs: 1000, ...(opts.config || {}) }),
|
config: () => ({ turnTimeoutMs: 1000, ...(opts.config || {}) }),
|
||||||
onReconfigure: opts.onReconfigure || (() => {}),
|
onReconfigure: opts.onReconfigure || (() => {}),
|
||||||
maxWorkers: opts.maxWorkers ?? 2,
|
maxWorkers: opts.maxWorkers ?? 2,
|
||||||
@ -118,131 +115,99 @@ async function until(fn, timeoutMs = 4000) {
|
|||||||
function jobs(lines) {
|
function jobs(lines) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const l of lines) {
|
for (const l of lines) {
|
||||||
const i = l.indexOf("JOB ");
|
const i = l.indexOf('JOB ');
|
||||||
if (i === -1) continue;
|
if (i === -1) continue;
|
||||||
try {
|
try { out.push(JSON.parse(l.slice(i + 4))); } catch { /* 不是完整一行 */ }
|
||||||
out.push(JSON.parse(l.slice(i + 4)));
|
|
||||||
} catch {
|
|
||||||
/* 不是完整一行 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 某个 mailID 的心跳出现过几次。 */
|
/** 某个 mailID 的心跳出现过几次。 */
|
||||||
const ticks = (lines, id) =>
|
const ticks = (lines, id) => lines.filter((l) => l.includes(`TICK ${id}`)).length;
|
||||||
lines.filter((l) => l.includes(`TICK ${id}`)).length;
|
|
||||||
|
|
||||||
test("并发上限被遵守:第三条会话要等前面空出来", async () => {
|
test('并发上限被遵守:第三条会话要等前面空出来', async () => {
|
||||||
const { pool } = makePool({ maxWorkers: 2 });
|
const { pool } = makePool({ maxWorkers: 2 });
|
||||||
for (const id of ["a", "b", "c"]) {
|
for (const id of ['a', 'b', 'c']) {
|
||||||
pool.submit("mail", { mail_id: id, session_id: `S-${id}`, __hold: 250 });
|
pool.submit('mail', { mail_id: id, session_id: `S-${id}`, __hold: 250 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => { peak = Math.max(peak, pool.stats().running); }, 15);
|
||||||
peak = Math.max(peak, pool.stats().running);
|
|
||||||
}, 15);
|
|
||||||
const sawQueue = await until(() => pool.stats().queued > 0, 1000);
|
const sawQueue = await until(() => pool.stats().queued > 0, 1000);
|
||||||
await until(() => pool.stats().running === 0 && pool.stats().queued === 0);
|
await until(() => pool.stats().running === 0 && pool.stats().queued === 0);
|
||||||
clearInterval(t);
|
clearInterval(t);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
assert.ok(peak <= 2, `同时跑的 worker 峰值 ${peak},不该超过 maxWorkers=2`);
|
assert.ok(peak <= 2, `同时跑的 worker 峰值 ${peak},不该超过 maxWorkers=2`);
|
||||||
assert.ok(sawQueue, "满载时第三封该进队列而不是被丢掉");
|
assert.ok(sawQueue, '满载时第三封该进队列而不是被丢掉');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("同一会话串行:两个 worker 的心跳不得重叠", async () => {
|
test('同一会话串行:两个 worker 的心跳不得重叠', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||||
pool.submit("mail", { mail_id: "m1", session_id: "SAME", __hold: 220 });
|
pool.submit('mail', { mail_id: 'm1', session_id: 'SAME', __hold: 220 });
|
||||||
pool.submit("mail", { mail_id: "m2", session_id: "SAME", __hold: 60 });
|
pool.submit('mail', { mail_id: 'm2', session_id: 'SAME', __hold: 60 });
|
||||||
|
|
||||||
// 判据一:任一时刻只有一个 worker 在跑。
|
// 判据一:任一时刻只有一个 worker 在跑。
|
||||||
let everTwo = false;
|
let everTwo = false;
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => { if (pool.stats().running > 1) everTwo = true; }, 10);
|
||||||
if (pool.stats().running > 1) everTwo = true;
|
await until(() => jobs(lines).length === 2 && pool.stats().running === 0, 5000);
|
||||||
}, 10);
|
|
||||||
await until(
|
|
||||||
() => jobs(lines).length === 2 && pool.stats().running === 0,
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
clearInterval(t);
|
clearInterval(t);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
assert.equal(everTwo, false, "同一条会话不得有两个 worker 同时装载会话文件");
|
assert.equal(everTwo, false, '同一条会话不得有两个 worker 同时装载会话文件');
|
||||||
// 判据二:m2 一次心跳都没能在 m1 结束前发出 —— m1 的心跳数应当远多于 m2。
|
// 判据二:m2 一次心跳都没能在 m1 结束前发出 —— m1 的心跳数应当远多于 m2。
|
||||||
assert.ok(
|
assert.ok(ticks(lines, 'm1') >= 3, `m1 该跑满 220ms,实际心跳 ${ticks(lines, 'm1')} 次`);
|
||||||
ticks(lines, "m1") >= 3,
|
assert.equal(jobs(lines).length, 2, '两封都要被处理,不能因为串行而丢掉一封');
|
||||||
`m1 该跑满 220ms,实际心跳 ${ticks(lines, "m1")} 次`,
|
|
||||||
);
|
|
||||||
assert.equal(jobs(lines).length, 2, "两封都要被处理,不能因为串行而丢掉一封");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("不同会话真并发:两个进程的心跳在同一段时间里交错", async () => {
|
test('不同会话真并发:两个进程的心跳在同一段时间里交错', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||||
pool.submit("mail", { mail_id: "p", session_id: "S-P", __hold: 300 });
|
pool.submit('mail', { mail_id: 'p', session_id: 'S-P', __hold: 300 });
|
||||||
pool.submit("mail", { mail_id: "q", session_id: "S-Q", __hold: 300 });
|
pool.submit('mail', { mail_id: 'q', session_id: 'S-Q', __hold: 300 });
|
||||||
|
|
||||||
const interleaved = await until(
|
const interleaved = await until(() => ticks(lines, 'p') >= 2 && ticks(lines, 'q') >= 2, 2500);
|
||||||
() => ticks(lines, "p") >= 2 && ticks(lines, "q") >= 2,
|
|
||||||
2500,
|
|
||||||
);
|
|
||||||
pool.stop();
|
pool.stop();
|
||||||
assert.ok(
|
assert.ok(interleaved,
|
||||||
interleaved,
|
`两条不同会话应当并发,实际 p=${ticks(lines, 'p')} q=${ticks(lines, 'q')} 次心跳`);
|
||||||
`两条不同会话应当并发,实际 p=${ticks(lines, "p")} q=${ticks(lines, "q")} 次心跳`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sessionFile 与 cwd 跨 worker 传下去:第二封接着第一封的会话谈", async () => {
|
test('sessionFile 与 cwd 跨 worker 传下去:第二封接着第一封的会话谈', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", { mail_id: "first", session_id: "KEEP", __hold: 30 });
|
pool.submit('mail', { mail_id: 'first', session_id: 'KEEP', __hold: 30 });
|
||||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||||
|
|
||||||
pool.submit("mail", { mail_id: "second", session_id: "KEEP", __hold: 30 });
|
pool.submit('mail', { mail_id: 'second', session_id: 'KEEP', __hold: 30 });
|
||||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
const [j1, j2] = jobs(lines);
|
const [j1, j2] = jobs(lines);
|
||||||
assert.equal(j1.sessionFile, "", "第一封时还没有会话文件");
|
assert.equal(j1.sessionFile, '', '第一封时还没有会话文件');
|
||||||
assert.equal(
|
assert.equal(j2.sessionFile, '/tmp/f-first.jsonl',
|
||||||
j2.sessionFile,
|
'第二封必须带上第一封开出来的会话文件,否则每封邮件都从零开始、上下文全丢');
|
||||||
"/tmp/f-first.jsonl",
|
assert.equal(j2.cwd, '/tmp', 'cwd 也要传下去');
|
||||||
"第二封必须带上第一封开出来的会话文件,否则每封邮件都从零开始、上下文全丢",
|
|
||||||
);
|
|
||||||
assert.equal(j2.cwd, "/tmp", "cwd 也要传下去");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("「一直同意」与命名指纹跨 worker 存活", async () => {
|
test('「一直同意」与命名指纹跨 worker 存活', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", {
|
pool.submit('mail', {
|
||||||
mail_id: "g1",
|
mail_id: 'g1', session_id: 'GRANT', __hold: 30,
|
||||||
session_id: "GRANT",
|
__grant: 'bash', __name: 'platform:某名字|某名字',
|
||||||
__hold: 30,
|
|
||||||
__grant: "bash",
|
|
||||||
__name: "platform:某名字|某名字",
|
|
||||||
});
|
});
|
||||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||||
|
|
||||||
pool.submit("mail", { mail_id: "g2", session_id: "GRANT", __hold: 30 });
|
pool.submit('mail', { mail_id: 'g2', session_id: 'GRANT', __hold: 30 });
|
||||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
const [j1, j2] = jobs(lines);
|
const [j1, j2] = jobs(lines);
|
||||||
assert.deepEqual(j1.grants, [], "第一封时还没人点过「一直同意」");
|
assert.deepEqual(j1.grants, [], '第一封时还没人点过「一直同意」');
|
||||||
assert.deepEqual(
|
assert.deepEqual(j2.grants, ['bash'],
|
||||||
j2.grants,
|
'「一直同意」不跨 worker 存活的话,下一封邮件又问一遍 —— 那个选项就是在骗人');
|
||||||
["bash"],
|
assert.equal(j2.lastSyncedName, 'platform:某名字|某名字',
|
||||||
"「一直同意」不跨 worker 存活的话,下一封邮件又问一遍 —— 那个选项就是在骗人",
|
'命名指纹要传下去,否则每封邮件都重新 sync 一次');
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
j2.lastSyncedName,
|
|
||||||
"platform:某名字|某名字",
|
|
||||||
"命名指纹要传下去,否则每封邮件都重新 sync 一次",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("config() 每次派活时重取:allowedModels 随心跳变,不能用快照", async () => {
|
test('config() 每次派活时重取:allowedModels 随心跳变,不能用快照', async () => {
|
||||||
let turnTimeoutMs = 111;
|
let turnTimeoutMs = 111;
|
||||||
const { pool, lines } = makePool({ maxWorkers: 1, config: {} });
|
const { pool, lines } = makePool({ maxWorkers: 1, config: {} });
|
||||||
// makePool 的 config 是固定值,这里换成动态的
|
// makePool 的 config 是固定值,这里换成动态的
|
||||||
@ -250,211 +215,162 @@ test("config() 每次派活时重取:allowedModels 随心跳变,不能用快
|
|||||||
|
|
||||||
const lines2 = [];
|
const lines2 = [];
|
||||||
const p2 = createWorkerPool({
|
const p2 = createWorkerPool({
|
||||||
log: (...a) => lines2.push(a.join(" ")),
|
log: (...a) => lines2.push(a.join(' ')),
|
||||||
config: () => ({ turnTimeoutMs }),
|
config: () => ({ turnTimeoutMs }),
|
||||||
onReconfigure: () => {},
|
onReconfigure: () => {},
|
||||||
maxWorkers: 1,
|
maxWorkers: 1,
|
||||||
workerMaxMs: 5000,
|
workerMaxMs: 5000,
|
||||||
workerPath: STUB,
|
workerPath: STUB,
|
||||||
});
|
});
|
||||||
p2.submit("mail", { mail_id: "c1", session_id: "C1", __hold: 20 });
|
p2.submit('mail', { mail_id: 'c1', session_id: 'C1', __hold: 20 });
|
||||||
await until(() => jobs(lines2).length === 1 && p2.stats().running === 0);
|
await until(() => jobs(lines2).length === 1 && p2.stats().running === 0);
|
||||||
turnTimeoutMs = 222;
|
turnTimeoutMs = 222;
|
||||||
p2.submit("mail", { mail_id: "c2", session_id: "C2", __hold: 20 });
|
p2.submit('mail', { mail_id: 'c2', session_id: 'C2', __hold: 20 });
|
||||||
await until(() => jobs(lines2).length === 2 && p2.stats().running === 0);
|
await until(() => jobs(lines2).length === 2 && p2.stats().running === 0);
|
||||||
p2.stop();
|
p2.stop();
|
||||||
|
|
||||||
const [j1, j2] = jobs(lines2);
|
const [j1, j2] = jobs(lines2);
|
||||||
assert.equal(j1.turnTimeoutMs, 111);
|
assert.equal(j1.turnTimeoutMs, 111);
|
||||||
assert.equal(
|
assert.equal(j2.turnTimeoutMs, 222, 'config() 必须每次重取,否则 worker 用的是上一轮的模型范围');
|
||||||
j2.turnTimeoutMs,
|
|
||||||
222,
|
|
||||||
"config() 必须每次重取,否则 worker 用的是上一轮的模型范围",
|
|
||||||
);
|
|
||||||
assert.equal(lines.length >= 0, true);
|
assert.equal(lines.length >= 0, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("硬超时回收卡死的 worker,且不堵住同会话后续邮件", async () => {
|
test('硬超时回收卡死的 worker,且不堵住同会话后续邮件', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2, workerMaxMs: 400 });
|
const { pool, lines } = makePool({ maxWorkers: 2, workerMaxMs: 400 });
|
||||||
pool.submit("mail", { mail_id: "stuck", session_id: "STUCK", __hold: -1 });
|
pool.submit('mail', { mail_id: 'stuck', session_id: 'STUCK', __hold: -1 });
|
||||||
await until(() => pool.stats().running === 1, 1500);
|
await until(() => pool.stats().running === 1, 1500);
|
||||||
|
|
||||||
const freed = await until(() => pool.stats().running === 0, 3000);
|
const freed = await until(() => pool.stats().running === 0, 3000);
|
||||||
assert.ok(
|
assert.ok(freed, '卡死的 worker 必须被硬超时回收,否则那条会话的后续邮件永远排队');
|
||||||
freed,
|
assert.ok(lines.some((l) => l.includes('强杀')), `应记下强杀日志,实际:\n${lines.join('\n')}`);
|
||||||
"卡死的 worker 必须被硬超时回收,否则那条会话的后续邮件永远排队",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
lines.some((l) => l.includes("强杀")),
|
|
||||||
`应记下强杀日志,实际:\n${lines.join("\n")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
pool.submit("mail", { mail_id: "after", session_id: "STUCK", __hold: 30 });
|
pool.submit('mail', { mail_id: 'after', session_id: 'STUCK', __hold: 30 });
|
||||||
const ran = await until(
|
const ran = await until(() => jobs(lines).some((j) => j.mailID === 'after'), 2000);
|
||||||
() => jobs(lines).some((j) => j.mailID === "after"),
|
|
||||||
2000,
|
|
||||||
);
|
|
||||||
await until(() => pool.stats().running === 0);
|
await until(() => pool.stats().running === 0);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
assert.ok(ran, "硬超时后同一会话的后续邮件必须能被处理");
|
assert.ok(ran, '硬超时后同一会话的后续邮件必须能被处理');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("权限决策路由到发起询问的那个 worker", async () => {
|
test('权限决策路由到发起询问的那个 worker', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", {
|
pool.submit('mail', { mail_id: 'perm', session_id: 'PERM', __pending: 'rk-1' });
|
||||||
mail_id: "perm",
|
|
||||||
session_id: "PERM",
|
|
||||||
__pending: "rk-1",
|
|
||||||
});
|
|
||||||
await until(() => pool.stats().running === 1, 1500);
|
await until(() => pool.stats().running === 1, 1500);
|
||||||
await sleep(150); // 等 permission_pending 到主进程
|
await sleep(150); // 等 permission_pending 到主进程
|
||||||
|
|
||||||
assert.equal(pool.routePermission("rk-1", "同意"), true, "应当路由成功");
|
assert.equal(pool.routePermission('rk-1', '同意'), true, '应当路由成功');
|
||||||
await until(() => pool.stats().running === 0, 2000);
|
await until(() => pool.stats().running === 0, 2000);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(lines.some((l) => l.includes('DECISION rk-1=同意')),
|
||||||
lines.some((l) => l.includes("DECISION rk-1=同意")),
|
`worker 应收到决策原文,实际:\n${lines.join('\n')}`);
|
||||||
`worker 应收到决策原文,实际:\n${lines.join("\n")}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("决策发的是选项原文而不是归一化的 allow/deny", async () => {
|
test('决策发的是选项原文而不是归一化的 allow/deny', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", { mail_id: "p2", session_id: "P2", __pending: "rk-2" });
|
pool.submit('mail', { mail_id: 'p2', session_id: 'P2', __pending: 'rk-2' });
|
||||||
await until(() => pool.stats().running === 1, 1500);
|
await until(() => pool.stats().running === 1, 1500);
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
pool.routePermission("rk-2", "一直同意");
|
pool.routePermission('rk-2', '一直同意');
|
||||||
await until(() => pool.stats().running === 0, 2000);
|
await until(() => pool.stats().running === 0, 2000);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
// 「同意」与「一直同意」语义不同,归一化会让后者退化成单次授权
|
// 「同意」与「一直同意」语义不同,归一化会让后者退化成单次授权
|
||||||
assert.ok(
|
assert.ok(lines.some((l) => l.includes('DECISION rk-2=一直同意')),
|
||||||
lines.some((l) => l.includes("DECISION rk-2=一直同意")),
|
`必须原文透传,实际:\n${lines.join('\n')}`);
|
||||||
`必须原文透传,实际:\n${lines.join("\n")}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("决策找不到 worker 时返回 false(调用方据此走 B-4.2 降级)", async () => {
|
test('决策找不到 worker 时返回 false(调用方据此走 B-4.2 降级)', async () => {
|
||||||
const { pool } = makePool();
|
const { pool } = makePool();
|
||||||
assert.equal(pool.routePermission("never-seen", "同意"), false);
|
assert.equal(pool.routePermission('never-seen', '同意'), false);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("worker 退出后它的权限路由被清掉,不会误投给下一个 worker", async () => {
|
test('worker 退出后它的权限路由被清掉,不会误投给下一个 worker', async () => {
|
||||||
const { pool } = makePool({ maxWorkers: 2 });
|
const { pool } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", {
|
pool.submit('mail', { mail_id: 'gone', session_id: 'GONE', __pending: 'rk-gone' });
|
||||||
mail_id: "gone",
|
|
||||||
session_id: "GONE",
|
|
||||||
__pending: "rk-gone",
|
|
||||||
});
|
|
||||||
await until(() => pool.stats().running === 1, 1500);
|
await until(() => pool.stats().running === 1, 1500);
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
// 不给决策,直接停掉它
|
// 不给决策,直接停掉它
|
||||||
pool.stop();
|
pool.stop();
|
||||||
await until(() => pool.stats().running === 0, 4000);
|
await until(() => pool.stats().running === 0, 4000);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(pool.routePermission('rk-gone', '同意'), false,
|
||||||
pool.routePermission("rk-gone", "同意"),
|
'worker 已退出,路由必须返回 false 让调用方走降级路径');
|
||||||
false,
|
|
||||||
"worker 已退出,路由必须返回 false 让调用方走降级路径",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mailDrivenIDs 报出所有跑过的 pi 会话,且不随 worker 退出而清", async () => {
|
test('mailDrivenIDs 报出所有跑过的 pi 会话,且不随 worker 退出而清', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", { mail_id: "d1", session_id: "D1", __hold: 30 });
|
pool.submit('mail', { mail_id: 'd1', session_id: 'D1', __hold: 30 });
|
||||||
pool.submit("mail", { mail_id: "d2", session_id: "D2", __hold: 30 });
|
pool.submit('mail', { mail_id: 'd2', session_id: 'D2', __hold: 30 });
|
||||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
const ids = pool.mailDrivenIDs();
|
const ids = pool.mailDrivenIDs();
|
||||||
assert.ok(ids.has("pi-d1"), "D1 的 pi 会话该被标记为邮件驱动");
|
assert.ok(ids.has('pi-d1'), 'D1 的 pi 会话该被标记为邮件驱动');
|
||||||
assert.ok(ids.has("pi-d2"), "D2 的 pi 会话该被标记为邮件驱动");
|
assert.ok(ids.has('pi-d2'), 'D2 的 pi 会话该被标记为邮件驱动');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("模型降级换掉的旧 pi 会话仍算邮件驱动", async () => {
|
test('模型降级换掉的旧 pi 会话仍算邮件驱动', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", {
|
pool.submit('mail', { mail_id: 'r', session_id: 'RETIRE', __hold: 40, __reopen: 'pi-new' });
|
||||||
mail_id: "r",
|
|
||||||
session_id: "RETIRE",
|
|
||||||
__hold: 40,
|
|
||||||
__reopen: "pi-new",
|
|
||||||
});
|
|
||||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
const ids = pool.mailDrivenIDs();
|
const ids = pool.mailDrivenIDs();
|
||||||
assert.ok(ids.has("pi-new"), "新会话要在");
|
assert.ok(ids.has('pi-new'), '新会话要在');
|
||||||
assert.ok(
|
assert.ok(ids.has('pi-r'), '被换掉的旧会话也参与过邮件往来,磁盘上的文件还在,快照该报它');
|
||||||
ids.has("pi-r"),
|
|
||||||
"被换掉的旧会话也参与过邮件往来,磁盘上的文件还在,快照该报它",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hasSession 只对跑过的邮件会话为真", async () => {
|
test('hasSession 只对跑过的邮件会话为真', async () => {
|
||||||
const { pool } = makePool();
|
const { pool } = makePool();
|
||||||
assert.equal(pool.hasSession("NOPE"), false);
|
assert.equal(pool.hasSession('NOPE'), false);
|
||||||
pool.submit("mail", { mail_id: "h1", session_id: "HAS", __hold: 30 });
|
pool.submit('mail', { mail_id: 'h1', session_id: 'HAS', __hold: 30 });
|
||||||
await until(() => pool.hasSession("HAS"), 2000);
|
await until(() => pool.hasSession('HAS'), 2000);
|
||||||
await until(() => pool.stats().running === 0);
|
await until(() => pool.stats().running === 0);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
assert.equal(pool.hasSession("HAS"), true, "worker 退出后仍该记着这条会话");
|
assert.equal(pool.hasSession('HAS'), true, 'worker 退出后仍该记着这条会话');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("kind 透传:权限通知走 permission 而不是 mail", async () => {
|
test('kind 透传:权限通知走 permission 而不是 mail', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("permission", { mail_id: "k1", session_id: "K1", __hold: 20 });
|
pool.submit('permission', { mail_id: 'k1', session_id: 'K1', __hold: 20 });
|
||||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
assert.equal(
|
assert.equal(jobs(lines)[0].kind, 'permission',
|
||||||
jobs(lines)[0].kind,
|
'kind 决定 worker 用哪套提示词,传错会让模型以为收到一封新邮件');
|
||||||
"permission",
|
|
||||||
"kind 决定 worker 用哪套提示词,传错会让模型以为收到一封新邮件",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stop 之后不再派活", async () => {
|
test('stop 之后不再派活', async () => {
|
||||||
const { pool } = makePool();
|
const { pool } = makePool();
|
||||||
pool.stop();
|
pool.stop();
|
||||||
pool.submit("mail", { mail_id: "late", session_id: "LATE", __hold: 30 });
|
pool.submit('mail', { mail_id: 'late', session_id: 'LATE', __hold: 30 });
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
assert.equal(pool.stats().running, 0, "关停后不该再起 worker");
|
assert.equal(pool.stats().running, 0, '关停后不该再起 worker');
|
||||||
assert.equal(pool.stats().queued, 0, "关停后队列应为空");
|
assert.equal(pool.stats().queued, 0, '关停后队列应为空');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stop 会先给 worker 发 shutdown(让它 fail closed)再杀", async () => {
|
test('stop 会先给 worker 发 shutdown(让它 fail closed)再杀', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||||
pool.submit("mail", { mail_id: "s1", session_id: "S1", __hold: -1 });
|
pool.submit('mail', { mail_id: 's1', session_id: 'S1', __hold: -1 });
|
||||||
await until(() => pool.stats().running === 1, 1500);
|
await until(() => pool.stats().running === 1, 1500);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
const gotShutdown = await until(
|
const gotShutdown = await until(() => lines.some((l) => l.includes('SHUTDOWN')), 2000);
|
||||||
() => lines.some((l) => l.includes("SHUTDOWN")),
|
assert.ok(gotShutdown,
|
||||||
2000,
|
'必须先发 shutdown:直接 SIGKILL 会让 pi 侧那些等权限的 await 永不返回');
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
gotShutdown,
|
|
||||||
"必须先发 shutdown:直接 SIGKILL 会让 pi 侧那些等权限的 await 永不返回",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("没有 session_id 的事件各占一个 key,不会互相串行", async () => {
|
test('没有 session_id 的事件各占一个 key,不会互相串行', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||||
pool.submit("mail", { mail_id: "n1", __hold: 300 });
|
pool.submit('mail', { mail_id: 'n1', __hold: 300 });
|
||||||
pool.submit("mail", { mail_id: "n2", __hold: 300 });
|
pool.submit('mail', { mail_id: 'n2', __hold: 300 });
|
||||||
const both = await until(
|
const both = await until(() => ticks(lines, 'n1') >= 2 && ticks(lines, 'n2') >= 2, 2500);
|
||||||
() => ticks(lines, "n1") >= 2 && ticks(lines, "n2") >= 2,
|
|
||||||
2500,
|
|
||||||
);
|
|
||||||
pool.stop();
|
pool.stop();
|
||||||
assert.ok(both, "无 session_id 的两封不属于同一条会话,应能并发");
|
assert.ok(both, '无 session_id 的两封不属于同一条会话,应能并发');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reconfigure 上报被转达给主进程", async () => {
|
test('reconfigure 上报被转达给主进程', async () => {
|
||||||
const STUB2 = join(STUB_DIR, "stub-reconf.mjs");
|
const STUB2 = join(STUB_DIR, 'stub-reconf.mjs');
|
||||||
writeFileSync(
|
writeFileSync(STUB2, `
|
||||||
STUB2,
|
|
||||||
`
|
|
||||||
process.on('message', (msg) => {
|
process.on('message', (msg) => {
|
||||||
if (msg?.type === 'job') {
|
if (msg?.type === 'job') {
|
||||||
process.send({ type: 'reconfigure', url: 'http://new:9999', agentKey: 'k2' });
|
process.send({ type: 'reconfigure', url: 'http://new:9999', agentKey: 'k2' });
|
||||||
@ -463,57 +379,42 @@ process.on('message', (msg) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
process.send({ type: 'ready' });
|
process.send({ type: 'ready' });
|
||||||
`,
|
`);
|
||||||
);
|
|
||||||
let got = null;
|
let got = null;
|
||||||
const { pool } = makePool({
|
const { pool } = makePool({
|
||||||
maxWorkers: 1,
|
maxWorkers: 1,
|
||||||
workerPath: STUB2,
|
workerPath: STUB2,
|
||||||
onReconfigure: (url, key) => {
|
onReconfigure: (url, key) => { got = { url, key }; },
|
||||||
got = { url, key };
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
pool.submit("mail", { mail_id: "r1", session_id: "R1" });
|
pool.submit('mail', { mail_id: 'r1', session_id: 'R1' });
|
||||||
await until(() => got !== null, 3000);
|
await until(() => got !== null, 3000);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
assert.deepEqual(
|
assert.deepEqual(got, { url: 'http://new:9999', key: 'k2' },
|
||||||
got,
|
'worker 里 connect_to_server 换的坐标必须回到主进程 —— worker 马上就退了,改在它自己身上等于没改');
|
||||||
{ url: "http://new:9999", key: "k2" },
|
|
||||||
"worker 里 connect_to_server 换的坐标必须回到主进程 —— worker 马上就退了,改在它自己身上等于没改",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("worker 未回报 done 就退出:有界重投而不是静默丢信", async () => {
|
test('worker 未回报 done 就退出:有界重投而不是静默丢信', async () => {
|
||||||
// maxAttempts=2:首次 + 一次重投,然后放弃。
|
// maxAttempts=2:首次 + 一次重投,然后放弃。
|
||||||
// 这封邮件必定崩溃,重投就是在验证「有界」——不然它会变成永久活锁。
|
// 这封邮件必定崩溃,重投就是在验证「有界」——不然它会变成永久活锁。
|
||||||
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 2 });
|
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 2 });
|
||||||
pool.submit("mail", {
|
pool.submit('mail', { mail_id: 'crashy', session_id: 'CRASH', __crash: true });
|
||||||
mail_id: "crashy",
|
|
||||||
session_id: "CRASH",
|
|
||||||
__crash: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gaveUp = await until(() => lines.some((l) => l.includes("放弃")), 6000);
|
const gaveUp = await until(() => lines.some((l) => l.includes('放弃')), 6000);
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
assert.ok(gaveUp, `重投到上限后应记下「放弃」,实际:\n${lines.join("\n")}`);
|
assert.ok(gaveUp, `重投到上限后应记下「放弃」,实际:\n${lines.join('\n')}`);
|
||||||
assert.equal(
|
assert.equal(jobs(lines).length, 2,
|
||||||
jobs(lines).length,
|
`应当尝试 2 次(首次 + 1 次重投),实际 ${jobs(lines).length} 次`);
|
||||||
2,
|
assert.ok(lines.some((l) => l.includes('未回报 done 就退出')),
|
||||||
`应当尝试 2 次(首次 + 1 次重投),实际 ${jobs(lines).length} 次`,
|
'必须明说是「未回报 done 就退出」——否则看到 exit code 会误以为是普通崩溃');
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
lines.some((l) => l.includes("未回报 done 就退出")),
|
|
||||||
"必须明说是「未回报 done 就退出」——否则看到 exit code 会误以为是普通崩溃",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("重投上限之下不会无限重投(maxAttempts=1 就是不重投)", async () => {
|
test('重投上限之下不会无限重投(maxAttempts=1 就是不重投)', async () => {
|
||||||
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 1 });
|
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 1 });
|
||||||
pool.submit("mail", { mail_id: "once", session_id: "ONCE", __crash: true });
|
pool.submit('mail', { mail_id: 'once', session_id: 'ONCE', __crash: true });
|
||||||
await until(() => lines.some((l) => l.includes("放弃")), 4000);
|
await until(() => lines.some((l) => l.includes('放弃')), 4000);
|
||||||
await sleep(300); // 再等一会儿,确认没有额外重投
|
await sleep(300); // 再等一会儿,确认没有额外重投
|
||||||
pool.stop();
|
pool.stop();
|
||||||
|
|
||||||
assert.equal(jobs(lines).length, 1, "maxAttempts=1 时只跑一次");
|
assert.equal(jobs(lines).length, 1, 'maxAttempts=1 时只跑一次');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,10 +9,10 @@
|
|||||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from 'node:test';
|
||||||
import assert from "node:assert/strict";
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { createFrameParser } from "../lib/sse-client.js";
|
import { createFrameParser } from '../lib/sse-client.js';
|
||||||
|
|
||||||
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
||||||
function parse(s) {
|
function parse(s) {
|
||||||
@ -23,118 +23,107 @@ function parse(s) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("完整帧一次喂入:正常解析", () => {
|
test('完整帧一次喂入:正常解析', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
|
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' });
|
||||||
assert.equal(events[0].id, "7");
|
assert.equal(events[0].id, '7');
|
||||||
assert.equal(p.lastEventId(), "7");
|
assert.equal(p.lastEventId(), '7');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
|
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
// chunk1 恰好停在 event 行之后、data 行之前
|
// chunk1 恰好停在 event 行之后、data 行之前
|
||||||
const first = p.push("id: 12\nevent: content_delta\n");
|
const first = p.push('id: 12\nevent: content_delta\n');
|
||||||
assert.deepEqual(first, [], "半帧不该派发");
|
assert.deepEqual(first, [], '半帧不该派发');
|
||||||
|
|
||||||
const second = p.push('data: {"x":1}\n\n');
|
const second = p.push('data: {"x":1}\n\n');
|
||||||
assert.equal(
|
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃');
|
||||||
second.length,
|
assert.equal(second[0].event, 'content_delta');
|
||||||
1,
|
assert.equal(p.lastEventId(), '12');
|
||||||
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
|
|
||||||
);
|
|
||||||
assert.equal(second[0].event, "content_delta");
|
|
||||||
assert.equal(p.lastEventId(), "12");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("帧被切在行中间:buffer 保留半行", () => {
|
test('帧被切在行中间:buffer 保留半行', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const a = p.push("event: new_ma");
|
const a = p.push('event: new_ma');
|
||||||
assert.deepEqual(a, []);
|
assert.deepEqual(a, []);
|
||||||
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
||||||
assert.equal(b.length, 1);
|
assert.equal(b.length, 1);
|
||||||
assert.equal(b[0].event, "new_mail");
|
assert.equal(b[0].event, 'new_mail');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("一个 chunk 里多帧连续:全部派发", () => {
|
test('一个 chunk 里多帧连续:全部派发', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push(
|
const events = p.push(
|
||||||
'event: new_mail\ndata: {"n":1}\n\n' +
|
'event: new_mail\ndata: {"n":1}\n\n' +
|
||||||
'event: new_mail\ndata: {"n":2}\n\n' +
|
'event: new_mail\ndata: {"n":2}\n\n' +
|
||||||
'event: session_update\ndata: {"n":3}\n\n',
|
'event: session_update\ndata: {"n":3}\n\n'
|
||||||
);
|
);
|
||||||
assert.equal(events.length, 3);
|
assert.equal(events.length, 3);
|
||||||
assert.deepEqual(
|
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']);
|
||||||
events.map((e) => e.event),
|
|
||||||
["new_mail", "new_mail", "session_update"],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("注释/心跳行被忽略,不影响后续帧", () => {
|
test('注释/心跳行被忽略,不影响后续帧', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("多行 data 用换行拼接", () => {
|
test('多行 data 用换行拼接', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
|
const events = p.push('event: x\ndata: line1\ndata: line2\n\n');
|
||||||
assert.equal(events[0].data, "line1\nline2");
|
assert.equal(events[0].data, 'line1\nline2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CRLF 不被当成事件名或 JSON 的一部分", () => {
|
test('CRLF 不被当成事件名或 JSON 的一部分', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
||||||
assert.equal(events.length, 1);
|
assert.equal(events.length, 1);
|
||||||
assert.equal(events[0].event, "new_mail");
|
assert.equal(events[0].event, 'new_mail');
|
||||||
assert.equal(events[0].id, "3");
|
assert.equal(events[0].id, '3');
|
||||||
assert.deepEqual(parse(events[0].data), { n: 1 });
|
assert.deepEqual(parse(events[0].data), { n: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
|
test('事件 id 只向前推进:重放旧 id 不回退断点', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "10");
|
assert.equal(p.lastEventId(), '10');
|
||||||
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
||||||
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
||||||
assert.equal(
|
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id(是否回退由使用方决定)');
|
||||||
p.lastEventId(),
|
|
||||||
"5",
|
|
||||||
"解析器如实记录当前 id(是否回退由使用方决定)",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("id 在派发前记录:回调抛异常也不丢断点", () => {
|
test('id 在派发前记录:回调抛异常也不丢断点', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "42");
|
assert.equal(p.lastEventId(), '42');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
|
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
const events = p.push('data: {"orphan":true}\n\n');
|
const events = p.push('data: {"orphan":true}\n\n');
|
||||||
assert.deepEqual(events, []);
|
assert.deepEqual(events, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
|
test('reset 清缓冲但保留断点(重连后仍能续传)', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
||||||
p.push("event: partial"); // 半帧
|
p.push('event: partial'); // 半帧
|
||||||
p.reset();
|
p.reset();
|
||||||
assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
|
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放');
|
||||||
// reset 后半帧不该复活
|
// reset 后半帧不该复活
|
||||||
const after = p.push('data: {"n":2}\n\n');
|
const after = p.push('data: {"n":2}\n\n');
|
||||||
assert.deepEqual(after, []);
|
assert.deepEqual(after, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
|
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => {
|
||||||
const p = createFrameParser();
|
const p = createFrameParser();
|
||||||
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
||||||
assert.equal(p.lastEventId(), "123");
|
assert.equal(p.lastEventId(), '123');
|
||||||
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
||||||
p.setLastEventId("");
|
p.setLastEventId('');
|
||||||
assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
|
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -404,3 +404,4 @@ func contains(list []string, v string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user