cortico-world-workbuddy 0.1.7 → 0.3.0
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/CHANGELOG.md +89 -0
- package/README.md +53 -76
- package/SECURITY.md +3 -3
- package/describe.json +9 -9
- package/dist/webui.js +5 -47
- package/package.json +7 -6
- package/scripts/build-web.mjs +97 -0
- package/scripts/contract.mjs +342 -0
- package/scripts/gen-describe.mjs +97 -0
- package/scripts/lib/app-root.mjs +41 -0
- package/scripts/reinstall.ps1 +79 -53
- package/scripts/selfcheck.mjs +58 -13
- package/scripts/verify-ui.mjs +12 -33
- package/src/config.js +4 -10
- package/src/credentials.js +9 -1
- package/src/promo.js +91 -33
- package/src/safety.js +8 -3
- package/src/upstream.js +82 -52
- package/src/usage.js +55 -22
- package/src/wbproc.js +6 -1
- package/src/webui/client.js +5 -48
- package/src/world.js +144 -192
- package/ENV_PROMPT.md +0 -11
- package/assets/workbuddy-home.js +0 -266
- package/scripts/host-inject.mjs +0 -80
- package/src/host.js +0 -269
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 重建 `dist/webui.js`(面板产物)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不用 `esbuild` 直接当 script:`prepack` 会在别人机器上跑,
|
|
6
|
+
* 那里可能没有 `npm install` 过(没有 node_modules/esbuild),直接调 `esbuild` 会
|
|
7
|
+
* `'esbuild' is not recognized`,整条 `npm pack` 就失败了。
|
|
8
|
+
*
|
|
9
|
+
* 解析顺序:
|
|
10
|
+
* 1. 本包的 devDependency(`node_modules/esbuild`,版本由 lock 固定,CI 用这个);
|
|
11
|
+
* 2. 宿主 Coopanion 自带的那份(`--config.strict-dep-builds` 之外的本机兜底);
|
|
12
|
+
* 3. 都没有:`dist/webui.js` 已存在就警告并退出 0(产物已随包提交),否则报错。
|
|
13
|
+
*
|
|
14
|
+
* `--strict`:找不到 esbuild 时**不允许**沿用旧产物(返回 1)。
|
|
15
|
+
* `prepack` 走 strict —— 否则没装依赖的机器会静默把过期 dist 发布出去(审查报告第 6 条)。
|
|
16
|
+
*
|
|
17
|
+
* 用法:node scripts/build-web.mjs [--verbose] [--strict]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync as readTextSync, statSync } from 'node:fs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const pkgDir = fileURLToPath(new URL('..', import.meta.url));
|
|
27
|
+
const verbose = process.argv.includes('--verbose');
|
|
28
|
+
const strict = process.argv.includes('--strict');
|
|
29
|
+
const entry = join(pkgDir, 'src', 'webui', 'client.js');
|
|
30
|
+
const outfile = join(pkgDir, 'dist', 'webui.js');
|
|
31
|
+
const args = [entry, '--bundle', '--format=esm', `--outfile=${outfile}`, '--log-level=warning'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 宿主 Coopanion 的 app 目录(只用来兜底取它自带的 esbuild)。
|
|
35
|
+
* 按「存在 build/console/index.html」判定,探测失败返回空串。
|
|
36
|
+
*/
|
|
37
|
+
function hostAppRoot() {
|
|
38
|
+
const local = process.env.LOCALAPPDATA || '';
|
|
39
|
+
const candidates = [
|
|
40
|
+
local && join(local, 'Programs', 'Coopanion', 'resources', 'app'),
|
|
41
|
+
process.env.ProgramFiles && join(process.env.ProgramFiles, 'Coopanion', 'resources', 'app'),
|
|
42
|
+
process.env['ProgramFiles(x86)'] && join(process.env['ProgramFiles(x86)'], 'Coopanion', 'resources', 'app'),
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
return candidates.find((dir) => existsSync(join(dir, 'build', 'console', 'index.html'))) ?? '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 找到可用的 esbuild 启动脚本(bin/esbuild 是 JS 包装,用 node 跑)。 */
|
|
48
|
+
function resolveEsbuild() {
|
|
49
|
+
const require = createRequire(import.meta.url);
|
|
50
|
+
const candidates = [];
|
|
51
|
+
try {
|
|
52
|
+
candidates.push(require.resolve('esbuild/bin/esbuild'));
|
|
53
|
+
} catch {
|
|
54
|
+
/* 本地没装 devDependencies,走宿主兜底 */
|
|
55
|
+
}
|
|
56
|
+
const appRoot = process.env.COOPANION_APP_ROOT || hostAppRoot();
|
|
57
|
+
if (appRoot) candidates.push(join(appRoot, 'node_modules', 'esbuild', 'bin', 'esbuild'));
|
|
58
|
+
|
|
59
|
+
for (const bin of candidates) {
|
|
60
|
+
if (!existsSync(bin)) continue;
|
|
61
|
+
// 版本号在同一目录的 package.json 里
|
|
62
|
+
let version = 'unknown';
|
|
63
|
+
try {
|
|
64
|
+
version = JSON.parse(readTextSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version;
|
|
65
|
+
} catch {
|
|
66
|
+
/* 版本读不到不影响构建 */
|
|
67
|
+
}
|
|
68
|
+
return { bin, version };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const found = resolveEsbuild();
|
|
74
|
+
if (!found) {
|
|
75
|
+
console.error('✗ 找不到 esbuild(本地 devDependencies 与宿主都没装)。');
|
|
76
|
+
console.error(' 先跑一次 `npm ci`(有 lockfile,锁的是 devDependency 的 esbuild)再重试。');
|
|
77
|
+
if (!existsSync(outfile)) process.exit(1);
|
|
78
|
+
if (strict) {
|
|
79
|
+
console.error('✗ --strict:拒绝沿用已有的 dist/webui.js(可能是过期产物)。');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
console.warn('! 非 strict 模式:沿用已有的 dist/webui.js(本地开发可以,发布不行)。');
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (verbose) console.log(`· esbuild ${found.version}:${found.bin}`);
|
|
87
|
+
mkdirSync(join(pkgDir, 'dist'), { recursive: true });
|
|
88
|
+
const result = spawnSync(process.execPath, [found.bin, ...args], { cwd: pkgDir, encoding: 'utf8' });
|
|
89
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
90
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
91
|
+
if (result.status !== 0) {
|
|
92
|
+
console.error(`✗ esbuild 失败(退出码 ${result.status})`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const size = statSync(outfile).size;
|
|
97
|
+
console.log(`✓ dist/webui.js 已重建(esbuild ${found.version},${size} 字节)`);
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 契约测试:不依赖 Coopanion 宿主,纯 Node 就能跑。
|
|
4
|
+
*
|
|
5
|
+
* 覆盖审查报告里「看起来对、其实悄悄失效」的那一类问题——静态清单与源码是否一致、
|
|
6
|
+
* 促销时间判定、安全清单分层、账本字段名,以及复检报告点名「没有回归断言」的那些行为:
|
|
7
|
+
* 哨兵语义、计费域选择、429 软硬区分、面板方法白名单、账本并发、SSE 尾行 flush、启动兜底。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node scripts/contract.mjs # 全部断言
|
|
11
|
+
* node scripts/contract.mjs --verbose # 逐条打印
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
import { buildDescribe, renderDescribe } from './gen-describe.mjs';
|
|
21
|
+
import { WB_DEFAULTS } from '../src/config.js';
|
|
22
|
+
import { attachPromos, parsePromos, priceViewOf } from '../src/promo.js';
|
|
23
|
+
import { classifyModel, CAUTION_MODEL_IDS, SAFE_MODEL_IDS } from '../src/safety.js';
|
|
24
|
+
import { UsageLedger, dayKey, summarize } from '../src/usage.js';
|
|
25
|
+
import { classifyHttpFailure, WbUpstream } from '../src/upstream.js';
|
|
26
|
+
import { PANEL_METHODS, TOKEN_AUTO, TOKEN_CLEARED, WorkBuddyWorld } from '../src/world.js';
|
|
27
|
+
|
|
28
|
+
const pkgDir = fileURLToPath(new URL('..', import.meta.url));
|
|
29
|
+
const verbose = process.argv.includes('--verbose');
|
|
30
|
+
|
|
31
|
+
let passed = 0;
|
|
32
|
+
const failures = [];
|
|
33
|
+
|
|
34
|
+
function ok(condition, label, detail) {
|
|
35
|
+
if (condition) {
|
|
36
|
+
passed += 1;
|
|
37
|
+
if (verbose) console.log(` ✓ ${label}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const text = detail === undefined ? label : `${label}(实际:${JSON.stringify(detail)})`;
|
|
41
|
+
failures.push(text);
|
|
42
|
+
if (verbose || failures.length <= 5) console.error(` ✗ ${text}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readJson(name) {
|
|
46
|
+
return JSON.parse(readFileSync(join(pkgDir, name), 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const pkg = readJson('package.json');
|
|
50
|
+
const describe = readJson('describe.json');
|
|
51
|
+
|
|
52
|
+
/* ---------------------------------------------------------------- 静态清单 */
|
|
53
|
+
|
|
54
|
+
console.log('· 清单与源码一致性');
|
|
55
|
+
ok(
|
|
56
|
+
renderDescribe(buildDescribe()) === readFileSync(join(pkgDir, 'describe.json'), 'utf8'),
|
|
57
|
+
'describe.json 与 WB_TOOL_DECLS / ENV_PROMPT.md 一致(跑 node scripts/gen-describe.mjs 可修)',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const changelog = readFileSync(join(pkgDir, 'CHANGELOG.md'), 'utf8');
|
|
61
|
+
ok(
|
|
62
|
+
new RegExp(`^## ${pkg.version.replace(/\./g, '\\.')}$`, 'm').test(changelog),
|
|
63
|
+
`CHANGELOG.md 里有 ## ${pkg.version} 段落`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
ok(pkg.cortico?.kind === 'world', 'cortico.kind = world');
|
|
67
|
+
ok(typeof pkg.cortico?.api === 'number', 'cortico.api 是数字(宿主按它判断兼容)');
|
|
68
|
+
ok(Array.isArray(pkg.cortico?.tags), 'cortico.tags 是数组(市场会渲染标签)');
|
|
69
|
+
ok(Array.isArray(pkg.cortico?.os) && pkg.cortico.os.includes('win32'), 'cortico.os 明确声明 win32');
|
|
70
|
+
|
|
71
|
+
for (const rel of ['src/index.js', pkg.cortico.describe, pkg.cortico.icon, pkg.cortico.webUiClient, pkg.cortico.webUiStyle]) {
|
|
72
|
+
ok(existsSync(join(pkgDir, rel)), `文件存在:${rel}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ok(!existsSync(join(pkgDir, 'ENV_PROMPT.md')), '包里没有那份旧的根 ENV_PROMPT.md(真正模板在 src/)');
|
|
76
|
+
for (const need of ['src', 'dist', 'assets', 'scripts', 'describe.json', 'README.md', 'CHANGELOG.md']) {
|
|
77
|
+
ok((pkg.files ?? []).includes(need), `files 覆盖 ${need}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ------------------------------------------- 环境提示词占位符 ↔ envPromptVars */
|
|
81
|
+
|
|
82
|
+
console.log('· 环境提示词变量');
|
|
83
|
+
const worldSource = readFileSync(join(pkgDir, 'src/world.js'), 'utf8');
|
|
84
|
+
const declared = new Set([...worldSource.matchAll(/'(workbuddy\.[A-Za-z]+)':/g)].map((m) => m[1]));
|
|
85
|
+
const used = new Set([...describe.envPrompt.matchAll(/\{\{(workbuddy\.[A-Za-z]+)\}\}/g)].map((m) => m[1]));
|
|
86
|
+
const missing = [...used].filter((name) => !declared.has(name));
|
|
87
|
+
ok(missing.length === 0, 'envPrompt 里每个 {{workbuddy.x}} 都有对应变量', missing);
|
|
88
|
+
ok(used.size >= 8, `envPrompt 用到了 ${used.size} 个变量`);
|
|
89
|
+
|
|
90
|
+
const toolNames = describe.tools.map((t) => t.name).sort();
|
|
91
|
+
ok(
|
|
92
|
+
toolNames.join(',') === ['wb_chat', 'wb_credits', 'wb_list_models', 'wb_select_model'].join(','),
|
|
93
|
+
'describe.tools 名字齐全',
|
|
94
|
+
);
|
|
95
|
+
ok(
|
|
96
|
+
describe.tools.every((t) => Array.isArray(t.tags) && t.parameters?.type === 'object'),
|
|
97
|
+
'每个工具都有 tags 与 object 参数',
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/* ------------------------------------------------------------------- 促销 */
|
|
101
|
+
|
|
102
|
+
console.log('· 促销与倍率');
|
|
103
|
+
const NOW = Date.parse('2026-11-03T23:30:00+08:00');
|
|
104
|
+
const promotions = [
|
|
105
|
+
{
|
|
106
|
+
id: 'night',
|
|
107
|
+
kind: 'discount',
|
|
108
|
+
enabled: true,
|
|
109
|
+
priority: 100,
|
|
110
|
+
modelIds: ['glm-5.2'],
|
|
111
|
+
discount: { discountedCredits: '0.50x', factor: 0.5, displayMode: 'strikethrough' },
|
|
112
|
+
schedule: { timezone: 'Asia/Shanghai', daily: [{ start: '23:00', end: '7:50' }] },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'badge-only',
|
|
116
|
+
kind: 'discount',
|
|
117
|
+
enabled: true,
|
|
118
|
+
priority: 500,
|
|
119
|
+
modelIds: ['glm-5.2'],
|
|
120
|
+
badge: { label: '白天徽标' },
|
|
121
|
+
schedule: { timezone: 'Asia/Shanghai', daily: [{ start: '00:00', end: '23:59' }] },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'expired',
|
|
125
|
+
kind: 'discount',
|
|
126
|
+
enabled: true,
|
|
127
|
+
priority: 900,
|
|
128
|
+
modelIds: ['glm-5.2'],
|
|
129
|
+
discount: { discountedCredits: '0x', displayMode: 'replace' },
|
|
130
|
+
schedule: { timezone: 'Asia/Shanghai', validUntil: '2020-01-01T00:00:00Z' },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'numeric',
|
|
134
|
+
kind: 'discount',
|
|
135
|
+
enabled: true,
|
|
136
|
+
priority: 60,
|
|
137
|
+
modelIds: ['hy3'],
|
|
138
|
+
discount: { discountedCredits: '0.25x', displayMode: 'replace' },
|
|
139
|
+
schedule: { timezone: 'Asia/Shanghai', validUntil: 1893456000 },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'bad-zone',
|
|
143
|
+
kind: 'discount',
|
|
144
|
+
enabled: true,
|
|
145
|
+
priority: 40,
|
|
146
|
+
modelIds: ['hunyuan-chat'],
|
|
147
|
+
discount: { discountedCredits: '0.10x', displayMode: 'replace' },
|
|
148
|
+
schedule: { timezone: 'Not/AZone', daily: [{ start: '00:00', end: '23:59' }] },
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
const promos = parsePromos(promotions, []);
|
|
152
|
+
const models = attachPromos(
|
|
153
|
+
[
|
|
154
|
+
{ id: 'glm-5.2', credits: 'x0.79' },
|
|
155
|
+
{ id: 'hy3', credits: 'x0' },
|
|
156
|
+
{ id: 'hunyuan-chat', credits: 'x0.30' },
|
|
157
|
+
],
|
|
158
|
+
promos,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const night = priceViewOf(models[0], NOW);
|
|
162
|
+
ok(night.promoId === 'night', '夜里挑的是真正改价的那条促销(不是优先级更高的徽标项)', night.promoId);
|
|
163
|
+
ok(night.effective === 0.5, '夜里生效倍率 = 0.5', night.effective);
|
|
164
|
+
ok(night.base === 0.79, '目录倍率 x0.79 解析正确', night.base);
|
|
165
|
+
ok(night.windowText === '23:00–07:50', '时段文案', night.windowText);
|
|
166
|
+
ok(night.discounted === true, 'discounted 标记为真');
|
|
167
|
+
|
|
168
|
+
const noon = priceViewOf(models[0], Date.parse('2026-11-03T12:00:00+08:00'));
|
|
169
|
+
ok(noon.effective === 0.79, '白天不打折', noon.effective);
|
|
170
|
+
ok(noon.promoId === undefined || noon.promoId === null, '白天不挂促销 id(过期那条与徽标条都不该改价)', noon.promoId);
|
|
171
|
+
|
|
172
|
+
const numeric = priceViewOf(models[1], NOW);
|
|
173
|
+
ok(numeric.promoId === 'numeric', '数字时间戳(Unix 秒)的有效期被识别', numeric.promoId);
|
|
174
|
+
|
|
175
|
+
const badZone = priceViewOf(models[2], NOW);
|
|
176
|
+
ok(badZone.promoId === 'bad-zone', '非法时区退回 UTC 后窗口仍生效', badZone.promoId);
|
|
177
|
+
|
|
178
|
+
/* ------------------------------------------------------------------- 安全 */
|
|
179
|
+
|
|
180
|
+
console.log('· 安全清单');
|
|
181
|
+
ok(SAFE_MODEL_IDS.includes('hy4-preview'), 'hy4-preview 在安全清单里');
|
|
182
|
+
ok(CAUTION_MODEL_IDS.includes('hy4-preview'), 'hy4-preview 标为谨慎档');
|
|
183
|
+
|
|
184
|
+
const hy4 = classifyModel('hy4-preview', { id: 'hy4-preview' });
|
|
185
|
+
ok(hy4.allow === true && hy4.caution === true, 'hy4-preview 可调用但带谨慎提示', hy4);
|
|
186
|
+
|
|
187
|
+
const lite = classifyModel('wb-lite', { id: 'wb-lite', disabled: true });
|
|
188
|
+
ok(lite.tier === 'disabled' && lite.allow === false, '目录标 disabled 的模型被拒绝', lite.tier);
|
|
189
|
+
|
|
190
|
+
const alias = classifyModel('auto', { id: 'auto' });
|
|
191
|
+
ok(alias.tier === 'alias' && alias.allow === false, '内部别名被拒绝', alias.tier);
|
|
192
|
+
|
|
193
|
+
const media = classifyModel('hunyuan-image-alpha', { id: 'hunyuan-image-alpha' });
|
|
194
|
+
ok(media.tier === 'media' && media.allow === false, '媒体模型被拒绝', media.tier);
|
|
195
|
+
|
|
196
|
+
const retired = classifyModel('glm-4.6', { id: 'glm-4.6' });
|
|
197
|
+
ok(retired.tier === 'retired' && retired.allow === false, '已下架模型被拒绝', retired.tier);
|
|
198
|
+
|
|
199
|
+
const unknown = classifyModel('totally-new-model', { id: 'totally-new-model' });
|
|
200
|
+
ok(unknown.allow === false, '未知模型默认不放行', unknown.allow);
|
|
201
|
+
|
|
202
|
+
/* ------------------------------------------------------------------- 账本 */
|
|
203
|
+
|
|
204
|
+
console.log('· 用量账本');
|
|
205
|
+
const nowMs = Date.parse('2026-11-03T10:00:00+08:00');
|
|
206
|
+
const records = [
|
|
207
|
+
{ ts: nowMs - 3600e3, model: 'deepseek-v4.1-flash', ok: true, promptTokens: 15, completionTokens: 4, totalTokens: 19, credit: 0, multiplier: 0.11 },
|
|
208
|
+
{ ts: nowMs - 3 * 864e5, model: 'glm-5.2', ok: true, promptTokens: 100, completionTokens: 50, totalTokens: 150, credit: 1.25, multiplier: 0.5 },
|
|
209
|
+
{ ts: nowMs - 40 * 864e5, model: 'hy3', ok: false, promptTokens: 0, completionTokens: 0, totalTokens: 0, credit: 0 },
|
|
210
|
+
];
|
|
211
|
+
const summary = summarize(records, nowMs, { days: 30 });
|
|
212
|
+
ok(summary.totals.today.calls === 1, '今天只统计今天那条', summary.totals.today.calls);
|
|
213
|
+
ok(summary.totals.week.calls === 2, '近 7 天含 3 天前那条', summary.totals.week.calls);
|
|
214
|
+
ok(summary.totals.month.calls === 2, '近 30 天不含 40 天前那条', summary.totals.month.calls);
|
|
215
|
+
ok(summary.totals.all.calls === 3, '全部含失败那条', summary.totals.all.calls);
|
|
216
|
+
ok(summary.totals.month.failed === 0, '近 30 天没有失败', summary.totals.month.failed);
|
|
217
|
+
ok(summary.recent[0].total === 19, '最近记录按时间倒序取到最新那条(total=19,不是最旧那条)', summary.recent[0].total);
|
|
218
|
+
ok(
|
|
219
|
+
summary.recent.every((r) => 'total' in r && 'prompt' in r && 'completion' in r),
|
|
220
|
+
'最近记录的字段名是 total/prompt/completion(面板按这三个名字读)',
|
|
221
|
+
);
|
|
222
|
+
ok(summary.byModel[0].model === 'glm-5.2', '按模型聚合按积分降序', summary.byModel.map((m) => m.model));
|
|
223
|
+
|
|
224
|
+
/* --------------------------------------------------------------- 回归防线 */
|
|
225
|
+
|
|
226
|
+
console.log('· 回归防线(复检报告第 9 条点名缺断言的行为)');
|
|
227
|
+
|
|
228
|
+
// 哨兵:wb-cleared 表示用户登出,wb-auto 表示「允许自动发现」
|
|
229
|
+
ok(TOKEN_CLEARED === 'wb-cleared' && TOKEN_AUTO === 'wb-auto', '两个凭据哨兵的取值稳定', [TOKEN_CLEARED, TOKEN_AUTO]);
|
|
230
|
+
|
|
231
|
+
const emptyCredDir = mkdtempSync(join(tmpdir(), 'wb-cred-'));
|
|
232
|
+
|
|
233
|
+
/** 造一个最小 World:只喂 loadTokens 需要的东西,避免碰真机凭据。 */
|
|
234
|
+
function makeWorld(secretValue) {
|
|
235
|
+
const ctx = {
|
|
236
|
+
id: 'workbuddy',
|
|
237
|
+
cfg: { ...WB_DEFAULTS, credentialsDir: emptyCredDir, model: 'deepseek-v4.1-flash' },
|
|
238
|
+
timezone: 'Asia/Shanghai',
|
|
239
|
+
botName: 'Coo',
|
|
240
|
+
botDir: emptyCredDir,
|
|
241
|
+
packageDir: pkgDir,
|
|
242
|
+
dataDir: mkdtempSync(join(tmpdir(), 'wb-data-')),
|
|
243
|
+
repoRoot: pkgDir,
|
|
244
|
+
secret: () => secretValue,
|
|
245
|
+
storeSecret: () => {},
|
|
246
|
+
persist: () => {},
|
|
247
|
+
restart: async () => {},
|
|
248
|
+
};
|
|
249
|
+
return new WorkBuddyWorld({ ctx });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const signedOutWorld = makeWorld(TOKEN_CLEARED);
|
|
253
|
+
await signedOutWorld.loadTokens();
|
|
254
|
+
ok(signedOutWorld.tokens === null, 'wb-cleared:不读数据目录凭据', signedOutWorld.tokens);
|
|
255
|
+
ok(/已登出|重新探测/.test(String(signedOutWorld.lastError ?? '')), 'wb-cleared:提示用户已登出(可重新探测)', signedOutWorld.lastError);
|
|
256
|
+
|
|
257
|
+
const autoWorld = makeWorld(TOKEN_AUTO);
|
|
258
|
+
await autoWorld.loadTokens();
|
|
259
|
+
ok(autoWorld.tokens === null, 'wb-auto:空目录下没有可用凭据', autoWorld.tokens);
|
|
260
|
+
ok(
|
|
261
|
+
!/已登出/.test(String(autoWorld.lastError ?? '')),
|
|
262
|
+
'wb-auto:不会把自己判成「已登出」(否则 rediscover 后重启又变未登录)',
|
|
263
|
+
autoWorld.lastError,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// 计费域:按区域,不按 baseUrl 猜;自定义 baseUrl 时跟着走同一出口
|
|
267
|
+
const cnUp = new WbUpstream({ baseUrl: 'https://copilot.tencent.com', region: 'cn' });
|
|
268
|
+
ok(cnUp.billingUrl('/x').startsWith('https://www.codebuddy.cn'), 'cn 计费域是 www.codebuddy.cn', cnUp.billingUrl('/x'));
|
|
269
|
+
const globalUp = new WbUpstream({ baseUrl: 'https://www.workbuddy.ai', region: 'global' });
|
|
270
|
+
ok(globalUp.billingUrl('/x').startsWith('https://www.workbuddy.ai'), 'global 计费域是 www.workbuddy.ai', globalUp.billingUrl('/x'));
|
|
271
|
+
const proxyUp = new WbUpstream({ baseUrl: 'https://proxy.example.com', region: 'cn' });
|
|
272
|
+
ok(proxyUp.billingUrl('/x') === 'https://proxy.example.com/x', '自定义 baseUrl 时计费走同一出口', proxyUp.billingUrl('/x'));
|
|
273
|
+
|
|
274
|
+
// 429 的软/硬区分
|
|
275
|
+
ok(classifyHttpFailure(429, '额度不足,请充值') === 'hard_credit', '429 + 额度文案 → hard_credit', classifyHttpFailure(429, '额度不足,请充值'));
|
|
276
|
+
ok(classifyHttpFailure(429, 'too many requests') === 'soft_rate', '429 普通限流 → soft_rate', classifyHttpFailure(429, 'too many requests'));
|
|
277
|
+
|
|
278
|
+
// 面板方法白名单
|
|
279
|
+
ok(
|
|
280
|
+
Array.isArray(PANEL_METHODS.account) && PANEL_METHODS.account.includes('setToken'),
|
|
281
|
+
'账号面板允许 setToken',
|
|
282
|
+
);
|
|
283
|
+
ok(!PANEL_METHODS.usage.includes('setToken'), '用量面板不能间接写密钥库(白名单生效)');
|
|
284
|
+
ok(
|
|
285
|
+
!PANEL_METHODS.priority.includes('host') && !PANEL_METHODS.priority.includes('repair'),
|
|
286
|
+
'0.3.0 删掉主页注入后,priority 面板不再有 host/repair',
|
|
287
|
+
PANEL_METHODS.priority,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// 账本并发:append 与 prune 串行,不互相覆盖
|
|
291
|
+
const ledger = new UsageLedger({ dataDir: mkdtempSync(join(tmpdir(), 'wb-ledger-')), log: { warn: () => {} } });
|
|
292
|
+
await Promise.all(
|
|
293
|
+
Array.from({ length: 30 }, (_, i) =>
|
|
294
|
+
ledger.append({ ts: Date.now() - i * 1000, model: 'deepseek-v4.1-flash', ok: true, promptTokens: 1, completionTokens: 1, totalTokens: 2, credit: 0 }),
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
await ledger.prune(Date.now());
|
|
298
|
+
const kept = await ledger.readAll();
|
|
299
|
+
ok(kept.length === 30, '并发 append + prune 后一条都没丢', kept.length);
|
|
300
|
+
|
|
301
|
+
// 「今天」按本地日期算,不是 UTC
|
|
302
|
+
ok(/getFullYear/.test(readFileSync(join(pkgDir, 'src', 'usage.js'), 'utf8')), 'dayKey 用本地日期字段(getFullYear),不用 toISOString');
|
|
303
|
+
const localDay = dayKey(Date.parse('2026-11-03T12:00:00+08:00'));
|
|
304
|
+
ok(/^\d{4}-\d{2}-\d{2}$/.test(localDay), 'dayKey 输出 YYYY-MM-DD', localDay);
|
|
305
|
+
|
|
306
|
+
// SSE:最后一段 data 没有换行时也要把 usage 处理掉(否则积分恒 0)
|
|
307
|
+
const sseServer = createServer((req, res) => {
|
|
308
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
309
|
+
res.write('data: {"code":0,"choices":[{"delta":{"content":"嗨"}}]}\n\n');
|
|
310
|
+
// 末尾故意不带换行:老实现会把这段丢掉
|
|
311
|
+
res.write('data: {"code":0,"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10,"credit":0.02}}');
|
|
312
|
+
res.end();
|
|
313
|
+
});
|
|
314
|
+
await new Promise((resolve) => sseServer.listen(0, '127.0.0.1', resolve));
|
|
315
|
+
const sseUrl = `http://127.0.0.1:${sseServer.address().port}`;
|
|
316
|
+
const sseUp = new WbUpstream({ baseUrl: sseUrl, region: 'cn' });
|
|
317
|
+
const chatOut = await sseUp.chat({ accessToken: 'test-token' }, { model: 'deepseek-v4.1-flash', messages: [{ role: 'user', content: 'hi' }], maxTokens: 64 }, () => {});
|
|
318
|
+
sseServer.close();
|
|
319
|
+
ok(chatOut.text === '嗨', 'SSE 正文拼接正确', chatOut.text);
|
|
320
|
+
ok(chatOut.usage?.totalTokens === 10, 'SSE 尾行(无换行)里的 usage 被解析', chatOut.usage);
|
|
321
|
+
ok(chatOut.credit === 0.02, 'SSE 尾行里的 credit 被解析', chatOut.credit);
|
|
322
|
+
|
|
323
|
+
// start() 兜底:pushEvent 失败也不能把 started 卡住(否则四盏灯永远「正在读取登录态…」)
|
|
324
|
+
const startWorld = makeWorld(TOKEN_CLEARED);
|
|
325
|
+
const failingHost = {
|
|
326
|
+
log: { info: () => {}, warn: () => {}, error: () => {} },
|
|
327
|
+
pushEvent: () => Promise.reject(new Error('boom')),
|
|
328
|
+
};
|
|
329
|
+
await startWorld.start(failingHost);
|
|
330
|
+
ok(startWorld.started === true, 'pushEvent 抛错时 start() 仍标记 started(灯态不卡住)');
|
|
331
|
+
await startWorld.stop();
|
|
332
|
+
|
|
333
|
+
/* ------------------------------------------------------------------- 结果 */
|
|
334
|
+
|
|
335
|
+
console.log('');
|
|
336
|
+
if (failures.length === 0) {
|
|
337
|
+
console.log(`✓ contract 通过:${passed} 条断言`);
|
|
338
|
+
process.exit(0);
|
|
339
|
+
}
|
|
340
|
+
console.error(`✗ contract 失败:${failures.length} 条(通过 ${passed} 条)`);
|
|
341
|
+
for (const f of failures.slice(0, 20)) console.error(` - ${f}`);
|
|
342
|
+
process.exit(1);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 生成 `describe.json`。
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它:`describe.json` 是给市场/宿主看的清单,`WB_TOOL_DECLS` 是运行时真正注册的
|
|
6
|
+
* 工具声明。两份各写各的,于是 0.1.7 里 `wb_chat` / `wb_credits` 的描述已经对不上了。
|
|
7
|
+
* 这里把 `describe.json` 变成生成物:
|
|
8
|
+
*
|
|
9
|
+
* - 工具名 / 描述 / 参数 / tags:来自 `src/world.js` 的 `WB_TOOL_DECLS`(唯一来源);
|
|
10
|
+
* - 中英摘要:本文件里的 `SUMMARIES`(市场展示用的短句,源码里没有这个概念);
|
|
11
|
+
* - 事件:本文件里的 `EVENTS`;
|
|
12
|
+
* - 环境提示词:`src/ENV_PROMPT.md` 全文。
|
|
13
|
+
*
|
|
14
|
+
* 用法:
|
|
15
|
+
* node scripts/gen-describe.mjs # 写回 describe.json
|
|
16
|
+
* node scripts/gen-describe.mjs --check # 只比对,不一致退出码 1
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { WB_TOOL_DECLS } from '../src/world.js';
|
|
23
|
+
|
|
24
|
+
const pkgDir = fileURLToPath(new URL('..', import.meta.url));
|
|
25
|
+
|
|
26
|
+
/** 市场列表里显示的一两句摘要(describe.json 的 summary 字段)。 */
|
|
27
|
+
const SUMMARIES = {
|
|
28
|
+
wb_list_models: {
|
|
29
|
+
zh: '列出 WorkBuddy 公开对话模型(安全清单)及各自的积分倍率、上下文与推理档;all=true 时附上被排除的项与原因。',
|
|
30
|
+
en: 'Lists the safe WorkBuddy chat models with multiplier, context and reasoning efforts; all=true also lists excluded ones with reasons.',
|
|
31
|
+
},
|
|
32
|
+
wb_select_model: {
|
|
33
|
+
zh: '选择 wb_chat 默认使用的 WorkBuddy 模型,选择会持久保存。',
|
|
34
|
+
en: 'Selects the default WorkBuddy model for wb_chat; the choice persists across restarts.',
|
|
35
|
+
},
|
|
36
|
+
wb_chat: {
|
|
37
|
+
zh: '向当前选中的 WorkBuddy 模型发一句单轮提问并取回答,会消耗用户自己的 WorkBuddy 积分。',
|
|
38
|
+
en: "Asks the selected WorkBuddy model a single-turn question; spends the user's own WorkBuddy credits.",
|
|
39
|
+
},
|
|
40
|
+
wb_credits: {
|
|
41
|
+
zh: '读取当前 WorkBuddy 账号的剩余积分与各积分包的已用/剩余(只读)。',
|
|
42
|
+
en: 'Shows remaining WorkBuddy credits and per-package usage (read-only).',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const EVENTS = [
|
|
47
|
+
{
|
|
48
|
+
type: 'workbuddy.started',
|
|
49
|
+
description: {
|
|
50
|
+
zh: 'World 挂载完成,报告登录态与目录概况。',
|
|
51
|
+
en: 'The World was mounted; reports sign-in and catalog status.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** 由源码生成 describe.json 的对象。 */
|
|
57
|
+
export function buildDescribe() {
|
|
58
|
+
const envPrompt = readFileSync(join(pkgDir, 'src', 'ENV_PROMPT.md'), 'utf8').replace(/\s+$/, '');
|
|
59
|
+
return {
|
|
60
|
+
tools: WB_TOOL_DECLS.map((decl) => ({
|
|
61
|
+
name: decl.name,
|
|
62
|
+
description: decl.description,
|
|
63
|
+
summary: SUMMARIES[decl.name] ?? { zh: decl.name, en: decl.name },
|
|
64
|
+
tags: [...(decl.tags ?? [])],
|
|
65
|
+
parameters: decl.parameters,
|
|
66
|
+
})),
|
|
67
|
+
events: EVENTS,
|
|
68
|
+
envPrompt,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 便于测试:把对象序列化成写入文件时的文本。 */
|
|
73
|
+
export function renderDescribe(obj = buildDescribe()) {
|
|
74
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
78
|
+
if (isMain) {
|
|
79
|
+
const target = join(pkgDir, 'describe.json');
|
|
80
|
+
const next = renderDescribe();
|
|
81
|
+
if (process.argv.includes('--check')) {
|
|
82
|
+
let current = '';
|
|
83
|
+
try {
|
|
84
|
+
current = readFileSync(target, 'utf8');
|
|
85
|
+
} catch {
|
|
86
|
+
current = '';
|
|
87
|
+
}
|
|
88
|
+
if (current !== next) {
|
|
89
|
+
console.error('✗ describe.json 与源码不一致;跑 `node scripts/gen-describe.mjs` 重新生成。');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
console.log('✓ describe.json 与源码一致');
|
|
93
|
+
} else {
|
|
94
|
+
writeFileSync(target, next, 'utf8');
|
|
95
|
+
console.log(`✓ 已生成 describe.json(tools ${WB_TOOL_DECLS.length} 个)`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 找 Coopanion 的 `resources/app` 目录(脚本共用)。
|
|
3
|
+
*
|
|
4
|
+
* 0.3.0 删掉了 `src/host.js`(不再注入宿主文件),但自检、UI 验证、构建脚本仍然需要
|
|
5
|
+
* 「宿主装在哪」这一个信息,所以把它单独抽成脚本侧的探测,扩展运行时不再依赖它。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{ packageDir?: string }} [opts]
|
|
13
|
+
* @returns {string} app 目录绝对路径;找不到返回空串
|
|
14
|
+
*/
|
|
15
|
+
export function findAppRoot(opts = {}) {
|
|
16
|
+
const candidates = [];
|
|
17
|
+
|
|
18
|
+
const fromEnv = process.env.COOPANION_APP_ROOT;
|
|
19
|
+
if (fromEnv) candidates.push(fromEnv);
|
|
20
|
+
|
|
21
|
+
// data-location.json 里记着 data 目录,app 在它旁边的 resources/app
|
|
22
|
+
const localApp = process.env.LOCALAPPDATA;
|
|
23
|
+
if (localApp) {
|
|
24
|
+
const dataLocation = join(localApp, 'Coopanion', 'data-location.json');
|
|
25
|
+
if (existsSync(dataLocation)) {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(readFileSync(dataLocation, 'utf8')).data;
|
|
28
|
+
if (typeof data === 'string' && data) candidates.push(join(dirname(data), 'resources', 'app'));
|
|
29
|
+
} catch {
|
|
30
|
+
/* 读不出来就走下面的默认位置 */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
candidates.push(join(localApp, 'Programs', 'Coopanion', 'resources', 'app'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const base of [process.env.ProgramFiles, process.env['ProgramFiles(x86)']]) {
|
|
37
|
+
if (base) candidates.push(join(base, 'Coopanion', 'resources', 'app'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return candidates.find((dir) => existsSync(join(dir, 'build', 'console', 'index.html'))) ?? '';
|
|
41
|
+
}
|