@xcanwin/manyoyo 7.0.23 → 7.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/serve-log-reader.js +178 -0
- package/lib/web/AGENTS.md +32 -0
- package/lib/web/frontend/shadcn.html +86 -86
- package/lib/web/server.js +107 -0
- package/package.json +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// serve 日志一行的格式(写入侧见 bin/manyoyo.js 的 createServeLogger):
|
|
6
|
+
// [<本地时间>] [pid:<pid>] [<LEVEL>] <message>[ <extra JSON>]
|
|
7
|
+
const SERVE_LOG_LINE_PATTERN = /^\[([^\]]+)\] \[pid:(\d+)\] \[([A-Z]+)\] (.*)$/;
|
|
8
|
+
const LOG_DATE_TAG_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LIMIT = 50;
|
|
11
|
+
const MAX_LIMIT = 500;
|
|
12
|
+
const DEFAULT_CHUNK_SIZE = 64 * 1024;
|
|
13
|
+
// 过滤条件很窄(比如只看 error)时可能扫很久都凑不满 limit,给一个扫描上限兜底,
|
|
14
|
+
// 避免一次请求把整份日志读完、把事件循环堵住
|
|
15
|
+
const MAX_SCAN_BYTES = 4 * 1024 * 1024;
|
|
16
|
+
const NEWLINE = 0x0a;
|
|
17
|
+
|
|
18
|
+
function isValidLogDateTag(value) {
|
|
19
|
+
return typeof value === 'string' && LOG_DATE_TAG_PATTERN.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// message 与 extra 之间没有分隔符,只能从右边找第一个能当 JSON 解析的 "{...}"。
|
|
23
|
+
// 解析不出来就整体当 message,不丢内容
|
|
24
|
+
function splitMessageAndExtra(rest) {
|
|
25
|
+
const text = String(rest || '');
|
|
26
|
+
const braceIndex = text.indexOf(' {');
|
|
27
|
+
if (braceIndex === -1) {
|
|
28
|
+
return { message: text.trim(), extra: {} };
|
|
29
|
+
}
|
|
30
|
+
const message = text.slice(0, braceIndex).trim();
|
|
31
|
+
const tail = text.slice(braceIndex + 1);
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(tail);
|
|
34
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
35
|
+
return { message, extra: parsed };
|
|
36
|
+
}
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// 落到下面的 raw 分支
|
|
39
|
+
}
|
|
40
|
+
return { message, extra: { raw: tail } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseServeLogLine(line) {
|
|
44
|
+
const matched = String(line || '').match(SERVE_LOG_LINE_PATTERN);
|
|
45
|
+
if (!matched) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const { message, extra } = splitMessageAndExtra(matched[4]);
|
|
49
|
+
return {
|
|
50
|
+
ts: matched[1],
|
|
51
|
+
pid: Number(matched[2]),
|
|
52
|
+
level: matched[3],
|
|
53
|
+
message,
|
|
54
|
+
extra
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeLimit(limit) {
|
|
59
|
+
const parsed = Number(limit);
|
|
60
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
61
|
+
return DEFAULT_LIMIT;
|
|
62
|
+
}
|
|
63
|
+
return Math.min(Math.floor(parsed), MAX_LIMIT);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildMatcher(options) {
|
|
67
|
+
const levels = Array.isArray(options.levels) && options.levels.length
|
|
68
|
+
? new Set(options.levels.map(level => String(level).toUpperCase()))
|
|
69
|
+
: null;
|
|
70
|
+
const keyword = options.keyword ? String(options.keyword).toLowerCase() : '';
|
|
71
|
+
const session = options.session ? String(options.session) : '';
|
|
72
|
+
return (entry, rawLine) => {
|
|
73
|
+
if (levels && !levels.has(entry.level)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (session && entry.extra.session !== session) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (keyword && !rawLine.toLowerCase().includes(keyword)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 从文件末尾往前分块读,凑够 limit 条就停——日志文件会长到几十 MB,
|
|
87
|
+
// 整份 readFileSync + split('\n') 是同步阻塞,会把 serve 的事件循环卡住
|
|
88
|
+
// (见 AGENTS.md 的日志约束)。用 Buffer 按换行字节切分,中文跨块也不会被截断。
|
|
89
|
+
// endOffset 是翻页游标:传上一页返回的 nextEndOffset 就能继续往更早翻
|
|
90
|
+
function readServeLogEntries(filePath, options = {}) {
|
|
91
|
+
const limit = normalizeLimit(options.limit);
|
|
92
|
+
const chunkSize = Number.isFinite(options.chunkSize) && options.chunkSize > 0
|
|
93
|
+
? Math.floor(options.chunkSize)
|
|
94
|
+
: DEFAULT_CHUNK_SIZE;
|
|
95
|
+
const empty = { entries: [], nextEndOffset: null, scannedBytes: 0, limit };
|
|
96
|
+
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = fs.statSync(filePath);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return empty;
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
return empty;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const size = stat.size;
|
|
108
|
+
const requestedEnd = Number(options.endOffset);
|
|
109
|
+
let end = Number.isFinite(requestedEnd) && requestedEnd >= 0 ? Math.min(requestedEnd, size) : size;
|
|
110
|
+
if (end <= 0) {
|
|
111
|
+
return empty;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const matches = buildMatcher(options);
|
|
115
|
+
const entries = [];
|
|
116
|
+
let scannedBytes = 0;
|
|
117
|
+
let pos = end;
|
|
118
|
+
// pending 里的字节在文件中的起始偏移就是 pos;开头那一行可能不完整,
|
|
119
|
+
// 要留到下一轮(读到更早的块)再拼
|
|
120
|
+
let pending = Buffer.alloc(0);
|
|
121
|
+
let unconsumedEnd = end;
|
|
122
|
+
let reachedLimit = false;
|
|
123
|
+
|
|
124
|
+
const fd = fs.openSync(filePath, 'r');
|
|
125
|
+
try {
|
|
126
|
+
while (pos > 0 && !reachedLimit && scannedBytes < MAX_SCAN_BYTES) {
|
|
127
|
+
const readSize = Math.min(chunkSize, pos);
|
|
128
|
+
pos -= readSize;
|
|
129
|
+
scannedBytes += readSize;
|
|
130
|
+
const buf = Buffer.alloc(readSize);
|
|
131
|
+
fs.readSync(fd, buf, 0, readSize, pos);
|
|
132
|
+
pending = pending.length ? Buffer.concat([buf, pending]) : buf;
|
|
133
|
+
|
|
134
|
+
let lineEnd = pending.length;
|
|
135
|
+
let newlineIndex = pending.lastIndexOf(NEWLINE, lineEnd - 1);
|
|
136
|
+
while (newlineIndex >= 0) {
|
|
137
|
+
const rawLine = pending.subarray(newlineIndex + 1, lineEnd).toString('utf-8');
|
|
138
|
+
lineEnd = newlineIndex;
|
|
139
|
+
const entry = parseServeLogLine(rawLine);
|
|
140
|
+
if (entry && matches(entry, rawLine)) {
|
|
141
|
+
entries.push(entry);
|
|
142
|
+
if (entries.length >= limit) {
|
|
143
|
+
reachedLimit = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
newlineIndex = pending.lastIndexOf(NEWLINE, lineEnd - 1);
|
|
148
|
+
}
|
|
149
|
+
pending = pending.subarray(0, lineEnd);
|
|
150
|
+
unconsumedEnd = pos + lineEnd;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 文件开头那一行前面没有换行符,单独收尾
|
|
154
|
+
if (!reachedLimit && pos === 0 && pending.length) {
|
|
155
|
+
const rawLine = pending.toString('utf-8');
|
|
156
|
+
const entry = parseServeLogLine(rawLine);
|
|
157
|
+
if (entry && matches(entry, rawLine)) {
|
|
158
|
+
entries.push(entry);
|
|
159
|
+
}
|
|
160
|
+
unconsumedEnd = 0;
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
fs.closeSync(fd);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
entries,
|
|
168
|
+
nextEndOffset: unconsumedEnd > 0 ? unconsumedEnd : null,
|
|
169
|
+
scannedBytes,
|
|
170
|
+
limit
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
isValidLogDateTag,
|
|
176
|
+
parseServeLogLine,
|
|
177
|
+
readServeLogEntries
|
|
178
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# lib/web/ 协作指引
|
|
2
|
+
|
|
3
|
+
根目录 `AGENTS.md` 的补充,仅在改动 Web 服务端与旧版前端时适用。
|
|
4
|
+
|
|
5
|
+
## lib/web/server.js(6000+ 行单文件)
|
|
6
|
+
|
|
7
|
+
与 `bin/manyoyo.js` 一样无分区注释,靠函数名定位:`Grep "^function <名>"`。它是仓库里最大的文件,改动前先确认要落在哪一层(HTTP 路由 / 会话历史读写 / 容器执行 / 流式协议),不要在路由 handler 里堆业务逻辑。
|
|
8
|
+
|
|
9
|
+
- `resolveYoloCommand()` 委托到 `lib/agent-adapters/index.js`,与 `bin/manyoyo.js` 共用同一份映射,无需分别维护。
|
|
10
|
+
- Agent 会话恢复参数:Claude/Gemini → `-r`,Codex → `resume`,OpenCode → `-c`。
|
|
11
|
+
- 会话控制事件:`/agent/stream`、`/agent/stop` 通过 `createWebStreamEmitter()` / `appendWebSessionControlEvent()` 写入 `lib/core/event-store.js`(`FileEventStore`),`GET /api/sessions/:name/audit` 导出该会话的事件与投影。
|
|
12
|
+
- **`/agent/stream` 的 NDJSON 事件协议是跨三处的契约**,改一处必须同步另两处:服务端 `lib/web/server.js`、新前端 `frontend-shadcn/src/lib/api.ts` 的 `StreamEvent` + `workspace-panel.tsx` 的事件分支、旧前端 `lib/web/frontend/app.js`
|
|
13
|
+
- `content_chunk`:token 级增量,前端**追加**;`reset: true` 表示换了一条 assistant 消息、从空白重新开始。只发新增片段,不要改成重发累计全文(几千个增量就是 O(n²) 流量)。
|
|
14
|
+
- `content_delta`:每条 assistant 消息落地时下发一次的权威全文,前端**整体覆盖**。
|
|
15
|
+
- `ping`:保活心跳,前端忽略。`DEFAULT_AGENT_STREAM_HEARTBEAT_MS` 必须明显小于反向代理的空闲读超时(nginx `proxy_read_timeout` 默认 60s),否则 agent 静默期间流会被 RST,浏览器侧表现为输入框上方冒出 `network error`。
|
|
16
|
+
- token 级增量**不写**事件日志、历史落盘按 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 节流;新增逐事件的落盘/日志动作前先想清楚它会不会被每个 token 触发一次。
|
|
17
|
+
- `GET /api/sessions` 是**同步 IO 大户**:逐容器 `readFileSync` + `JSON.parse` 整份历史。前端每标签页 6s 轮询一次,它阻塞多久事件循环就卡多久,正在跑的 `/agent/stream` 只能在空隙里把输出攒着分批推。往这条路径上加同步操作前务必实测耗时;`buildSessionSummary()` 已支持传入调用方加载好的 history,不要再重复读。(`docker ps -a` + 批量 `inspect` 实测仅约 90ms,不是瓶颈,别被旧注释误导)
|
|
18
|
+
- 请求处理链路上注册在**全局认证网关之前**的代码(如 `res.on('finish')` 之类的钩子)必须自带 try/catch:那里抛异常会冒泡成 `uncaughtException`,而 `bin/manyoyo.js` 的处理器直接 `process.exit(1)`——等于未认证请求可打挂 serve。回归用例见 `test/web-server-auth.test.js` 的 `Web Server Robustness`。
|
|
19
|
+
- 终端 vendor 资源(`/app/vendor/xterm.css`、`xterm.js`、`xterm-addon-fit.js`)由本文件从 `@xterm/*` 依赖映射提供。
|
|
20
|
+
- 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
|
|
21
|
+
|
|
22
|
+
## lib/web/frontend/(旧版 `/legacy` 前端)
|
|
23
|
+
|
|
24
|
+
默认前端是 `frontend-shadcn/`(构建产物 `shadcn.html` 也落在本目录);以下仅针对旧版静态资源。`chat-behavior.js` 是抽出的纯函数模块,仿 `markdown-renderer.js` 的 `window.Manyoyo*` + Node `vm` 单测模式。
|
|
25
|
+
|
|
26
|
+
- 布局是两层 grid 嵌套:`.main`(`header` + `.workspace-shell` 两个直接子元素)套着内层 `.workspace-main`(`grid-template-rows: minmax(0, 1fr) auto`,真正的「内容区 / composer」二分在这一层,composer 并非 `.main` 的直接子元素)。增删 `.workspace-main` 直接子元素时须同步调整行数,否则内容区高度失效。
|
|
27
|
+
- 中间工作台的「终端/文件/详情/配置/检查」5 个次要标签收在 `#workspaceSwitcherToggle` 图标按钮触发的弹出面板 `#workspaceSwitcherPanel` 里,仅「活动」作为常驻标签;`setActiveTab()` / `isActiveSessionHistoryOnly()` 逻辑本身未变,只是触发入口从常驻按钮改为面板内按钮。
|
|
28
|
+
- `connectTerminal()` 前须加 `isActiveSessionHistoryOnly()` 守卫(三处:`setActiveTab`、`handleSessionItemClick`、`refreshSessions`),否则点击「仅历史」会话会触发后端新建容器。
|
|
29
|
+
|
|
30
|
+
## 测试
|
|
31
|
+
|
|
32
|
+
Web 相关改动优先补 `test/web-server-auth.test.js`;涉及认证时至少验证未登录 `401`、登录成功可访问、登出后失效。
|