@konnga/dsh-hud 0.9.1 → 0.10.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/README.en.md +208 -9
- package/README.md +132 -82
- package/cordis.patch.yml +1 -1
- package/docs/images/balance-hover.webp +0 -0
- package/lib/client.js +7036 -4940
- package/lib/client.js.map +1 -0
- package/lib/host/git.js +188 -0
- package/lib/host/image.js +265 -0
- package/lib/host/index.js +106 -0
- package/lib/host/parse.js +297 -0
- package/lib/host/provider-balance.js +311 -0
- package/lib/host/providers.js +531 -0
- package/lib/host/readers.js +458 -0
- package/lib/host/rpc.js +395 -0
- package/lib/host/services.js +210 -0
- package/lib/host/session.js +1009 -0
- package/lib/host/types.js +12 -0
- package/package.json +20 -8
- package/lib/index.js +0 -2333
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git 输出的纯解析器:`git status --porcelain` / unified diff / `--name-status`。
|
|
3
|
+
*
|
|
4
|
+
* 全是**纯函数**(只吃字符串、只吐结构),没有 I/O、没有 ctx、也没有模块级状态 ——
|
|
5
|
+
* 所以它们最先从 `index.ts` 里搬出来:搬运零风险,且 diff 那几家读法都靠它们。
|
|
6
|
+
*/
|
|
7
|
+
/** 改动清单上限;超出只计数并置 `truncated`。 */
|
|
8
|
+
export const MAX_CHANGES = 200;
|
|
9
|
+
/** 单文件路径上限,防御异常仓库。 */
|
|
10
|
+
/**
|
|
11
|
+
* 解析 `git status --porcelain=v2 --branch -z` 的输出。
|
|
12
|
+
*
|
|
13
|
+
* porcelain-v2 的记录以 NUL 分隔,重命名/复制的记录会多跟一条 NUL 记录作为原路径。
|
|
14
|
+
* 路径本身可能含空格,所以绝不能整行 `split(' ')` —— 按字段数用 `indexOf` 切分。
|
|
15
|
+
*
|
|
16
|
+
* @param raw - git 的原始 stdout。
|
|
17
|
+
* @returns 与 `HudGitStatus` 同形的结果。
|
|
18
|
+
*/
|
|
19
|
+
export function parseStatus(raw) {
|
|
20
|
+
const records = String(raw).split('\0');
|
|
21
|
+
let branch = null;
|
|
22
|
+
let oid = null;
|
|
23
|
+
let upstream = null;
|
|
24
|
+
let ahead = 0;
|
|
25
|
+
let behind = 0;
|
|
26
|
+
let detached = false;
|
|
27
|
+
let truncated = false;
|
|
28
|
+
const counts = {
|
|
29
|
+
modified: 0,
|
|
30
|
+
added: 0,
|
|
31
|
+
deleted: 0,
|
|
32
|
+
renamed: 0,
|
|
33
|
+
copied: 0,
|
|
34
|
+
typeChanged: 0,
|
|
35
|
+
untracked: 0,
|
|
36
|
+
conflicted: 0,
|
|
37
|
+
};
|
|
38
|
+
const changes = [];
|
|
39
|
+
/** 跳过 `count` 个空格分隔的字段,返回其后剩余部分(保留路径里的空格)。 */
|
|
40
|
+
const afterFields = (record, count) => {
|
|
41
|
+
let index = 0;
|
|
42
|
+
for (let seen = 0; seen < count; seen += 1) {
|
|
43
|
+
const space = record.indexOf(' ', index);
|
|
44
|
+
if (space < 0)
|
|
45
|
+
return null;
|
|
46
|
+
index = space + 1;
|
|
47
|
+
}
|
|
48
|
+
return record.slice(index);
|
|
49
|
+
};
|
|
50
|
+
const push = (status, index, worktree, file, origFile) => {
|
|
51
|
+
if (changes.length < MAX_CHANGES) {
|
|
52
|
+
changes.push({
|
|
53
|
+
status,
|
|
54
|
+
index,
|
|
55
|
+
worktree,
|
|
56
|
+
path: origFile ? `${origFile} → ${file}` : file,
|
|
57
|
+
file,
|
|
58
|
+
origFile,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
truncated = true;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
for (let cursor = 0; cursor < records.length; cursor += 1) {
|
|
66
|
+
const record = records[cursor];
|
|
67
|
+
if (!record)
|
|
68
|
+
continue;
|
|
69
|
+
if (record.startsWith('# branch.head ')) {
|
|
70
|
+
const value = record.slice('# branch.head '.length);
|
|
71
|
+
detached = value === '(detached)';
|
|
72
|
+
branch = detached ? null : value;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (record.startsWith('# branch.oid ')) {
|
|
76
|
+
const value = record.slice('# branch.oid '.length);
|
|
77
|
+
oid = value === '(initial)' ? null : value.slice(0, 7);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (record.startsWith('# branch.upstream ')) {
|
|
81
|
+
upstream = record.slice('# branch.upstream '.length) || null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (record.startsWith('# branch.ab ')) {
|
|
85
|
+
const match = /\+(\d+) -(\d+)/.exec(record);
|
|
86
|
+
ahead = Number(match?.[1] ?? 0);
|
|
87
|
+
behind = Number(match?.[2] ?? 0);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (record.startsWith('? ')) {
|
|
91
|
+
counts.untracked += 1;
|
|
92
|
+
push('?', '.', '?', record.slice(2));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (record.startsWith('! ')) {
|
|
96
|
+
// 未使用 --ignored,正常不会出现;出现时按未跟踪处理更贴近用户直觉。
|
|
97
|
+
counts.untracked += 1;
|
|
98
|
+
push('!', '.', '!', record.slice(2));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (record.startsWith('u ')) {
|
|
102
|
+
counts.conflicted += 1;
|
|
103
|
+
const file = afterFields(record, 10);
|
|
104
|
+
if (file !== null)
|
|
105
|
+
push('!', record.slice(2, 4)[0], record.slice(2, 4)[1], file);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (record.startsWith('1 ') || record.startsWith('2 ')) {
|
|
109
|
+
const renamed = record.startsWith('2 ');
|
|
110
|
+
const xy = record.slice(2, 4);
|
|
111
|
+
// 展示字母:优先取 index 侧,index 为 '.' 时取 worktree 侧(与 codex-hud 同规则)。
|
|
112
|
+
const letter = xy[0] !== '.' ? xy[0] : xy[1];
|
|
113
|
+
const file = afterFields(record, renamed ? 9 : 8);
|
|
114
|
+
let origFile = null;
|
|
115
|
+
if (renamed) {
|
|
116
|
+
// 重命名/复制的原路径是紧随其后的一条 NUL 记录。
|
|
117
|
+
cursor += 1;
|
|
118
|
+
origFile = records[cursor] || null;
|
|
119
|
+
}
|
|
120
|
+
if (letter === 'R')
|
|
121
|
+
counts.renamed += 1;
|
|
122
|
+
else if (letter === 'C')
|
|
123
|
+
counts.copied += 1;
|
|
124
|
+
else if (letter === 'A')
|
|
125
|
+
counts.added += 1;
|
|
126
|
+
else if (letter === 'D')
|
|
127
|
+
counts.deleted += 1;
|
|
128
|
+
else if (letter === 'T')
|
|
129
|
+
counts.typeChanged += 1;
|
|
130
|
+
else
|
|
131
|
+
counts.modified += 1;
|
|
132
|
+
if (file !== null)
|
|
133
|
+
push(letter, xy[0], xy[1], file, origFile);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const dirty = Object.values(counts).some((count) => count > 0);
|
|
137
|
+
return {
|
|
138
|
+
repo: null, // 由调用方填入仓库根
|
|
139
|
+
branch: branch ?? oid,
|
|
140
|
+
detached,
|
|
141
|
+
oid,
|
|
142
|
+
upstream,
|
|
143
|
+
ahead,
|
|
144
|
+
behind,
|
|
145
|
+
dirty,
|
|
146
|
+
counts,
|
|
147
|
+
changes,
|
|
148
|
+
truncated,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** 单侧 diff 的文本上限(先按行截断再解析,保证解析量有界)。 */
|
|
152
|
+
export const MAX_DIFF_CHARS = 200_000;
|
|
153
|
+
/** 单侧 diff 的行数上限。 */
|
|
154
|
+
export const MAX_DIFF_ROWS = 2_000;
|
|
155
|
+
/** diff 行的种类:增 / 删 / 上下文 / hunk 头 / git 自己的文件头。 */
|
|
156
|
+
export const DIFF_ADD = 'add';
|
|
157
|
+
export const DIFF_DEL = 'del';
|
|
158
|
+
export const DIFF_CTX = 'ctx';
|
|
159
|
+
export const DIFF_HUNK = 'hunk';
|
|
160
|
+
export const DIFF_META = 'meta';
|
|
161
|
+
/**
|
|
162
|
+
* 把 `git diff` 文本解析成带行号的 rows。
|
|
163
|
+
*
|
|
164
|
+
* 放在宿主半而不是浏览器半:解析是纯函数,可以直接单测,客户端只负责渲染。
|
|
165
|
+
* 行号从每个 hunk 头 `@@ -a,b +c,d @@` 起算;git 自己的文件头(`diff --git`、
|
|
166
|
+
* `index`、`---`、`+++`)只在 hunk 之外识别,因此**内容行**里以 `--`/`++`/`@@`
|
|
167
|
+
* 开头的文本不会被误判。
|
|
168
|
+
*
|
|
169
|
+
* @param raw - git 的原始 stdout。
|
|
170
|
+
* @returns `{ rows, additions, deletions, hunks, binary, truncated }`。
|
|
171
|
+
*/
|
|
172
|
+
export function parseDiff(raw) {
|
|
173
|
+
const lines = String(raw).split('\n');
|
|
174
|
+
const rows = [];
|
|
175
|
+
let oldLine = 0;
|
|
176
|
+
let newLine = 0;
|
|
177
|
+
let inHunk = false;
|
|
178
|
+
let additions = 0;
|
|
179
|
+
let deletions = 0;
|
|
180
|
+
let hunks = 0;
|
|
181
|
+
let binary = false;
|
|
182
|
+
let truncated = false;
|
|
183
|
+
const push = (kind, text, old, next) => {
|
|
184
|
+
if (rows.length >= MAX_DIFF_ROWS) {
|
|
185
|
+
truncated = true;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
rows.push({ kind, text, oldLine: old, newLine: next });
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
192
|
+
const line = lines[index];
|
|
193
|
+
// 末尾那一行空串是 split 的产物,不是 diff 内容。
|
|
194
|
+
if (index === lines.length - 1 && line === '')
|
|
195
|
+
break;
|
|
196
|
+
if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) {
|
|
197
|
+
binary = true;
|
|
198
|
+
push(DIFF_META, line, null, null);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (line.startsWith('diff --git ')) {
|
|
202
|
+
// 多文件 diff(路径是目录时):新的一段从这里开始。
|
|
203
|
+
inHunk = false;
|
|
204
|
+
push(DIFF_META, line, null, null);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
208
|
+
if (hunk !== null) {
|
|
209
|
+
inHunk = true;
|
|
210
|
+
hunks += 1;
|
|
211
|
+
oldLine = Number(hunk[1]);
|
|
212
|
+
newLine = Number(hunk[2]);
|
|
213
|
+
push(DIFF_HUNK, line, null, null);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!inHunk) {
|
|
217
|
+
push(DIFF_META, line, null, null);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (line.startsWith('+')) {
|
|
221
|
+
additions += 1;
|
|
222
|
+
push(DIFF_ADD, line.slice(1), null, newLine);
|
|
223
|
+
newLine += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (line.startsWith('-')) {
|
|
227
|
+
deletions += 1;
|
|
228
|
+
push(DIFF_DEL, line.slice(1), oldLine, null);
|
|
229
|
+
oldLine += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (line.startsWith(' ')) {
|
|
233
|
+
push(DIFF_CTX, line.slice(1), oldLine, newLine);
|
|
234
|
+
oldLine += 1;
|
|
235
|
+
newLine += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// 反斜杠行("")等。
|
|
239
|
+
push(DIFF_META, line, null, null);
|
|
240
|
+
}
|
|
241
|
+
return { rows, additions, deletions, hunks, binary, truncated };
|
|
242
|
+
}
|
|
243
|
+
/** 按行边界截断文本。 */
|
|
244
|
+
export function truncateDiff(text) {
|
|
245
|
+
if (text.length <= MAX_DIFF_CHARS)
|
|
246
|
+
return { text, truncated: false };
|
|
247
|
+
const head = text.slice(0, MAX_DIFF_CHARS);
|
|
248
|
+
const lastBreak = head.lastIndexOf('\n');
|
|
249
|
+
return { text: lastBreak > 0 ? head.slice(0, lastBreak) : head, truncated: true };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 解析 `git diff --name-status -z` 的输出。
|
|
253
|
+
*
|
|
254
|
+
* `-z` 用 NUL 收尾,所以含空格/引号的路径原样给出(不会像默认输出那样带引号转义):
|
|
255
|
+
* 一段是 `STATUS\0PATH\0`,重命名/复制则是 `R075\0OLD\0NEW\0`。返回的每条与
|
|
256
|
+
* `status.changes` 同形(`status` 只留 M/A/D/R/C/T/U/X 这类单字母),这样左栏的
|
|
257
|
+
* 改动清单组件可以直接渲染这次的提交。
|
|
258
|
+
*
|
|
259
|
+
* @param raw - git 的原始 stdout。
|
|
260
|
+
* @returns `{ files, truncated }`,条数上限与工作区清单一致。
|
|
261
|
+
*/
|
|
262
|
+
export function parseNameStatus(raw) {
|
|
263
|
+
const tokens = String(raw).split('\0');
|
|
264
|
+
const files = [];
|
|
265
|
+
let truncated = false;
|
|
266
|
+
for (let cursor = 0; cursor < tokens.length;) {
|
|
267
|
+
const status = tokens[cursor];
|
|
268
|
+
cursor += 1;
|
|
269
|
+
// 收尾的 NUL 会留一个空段;非状态行的段落一律跳过,而不是把垃圾当路径。
|
|
270
|
+
if (!status || !/^[A-Z]/.test(status))
|
|
271
|
+
continue;
|
|
272
|
+
const letter = status.slice(0, 1);
|
|
273
|
+
const paired = letter === 'R' || letter === 'C';
|
|
274
|
+
const first = tokens[cursor];
|
|
275
|
+
cursor += 1;
|
|
276
|
+
const second = paired ? tokens[cursor] : null;
|
|
277
|
+
if (paired)
|
|
278
|
+
cursor += 1;
|
|
279
|
+
if (typeof first !== 'string' || first === '')
|
|
280
|
+
continue;
|
|
281
|
+
const file = paired ? second : first;
|
|
282
|
+
const origFile = paired ? first : null;
|
|
283
|
+
if (typeof file !== 'string' || file === '')
|
|
284
|
+
continue;
|
|
285
|
+
if (files.length >= MAX_CHANGES) {
|
|
286
|
+
truncated = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
files.push({
|
|
290
|
+
status: letter,
|
|
291
|
+
path: origFile === null ? file : `${origFile} → ${file}`,
|
|
292
|
+
file,
|
|
293
|
+
origFile,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return { files, truncated };
|
|
297
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「按当前会话的模型来源查余额」——宿主半的编排层。
|
|
3
|
+
*
|
|
4
|
+
* 这一层只做四件事,协议细节全在 `providers.ts`:
|
|
5
|
+
*
|
|
6
|
+
* 1. **定位账本**:`sessionId` → 会话 → `modelSelection` 投影 → 提供方 id。
|
|
7
|
+
* 2. **查它的连接事实**:从「设置 → 模型」的实时投影里读 `baseURL` 与凭据引用名
|
|
8
|
+
* (不另存一份,避免与用户改动漂移)。
|
|
9
|
+
* 3. **取密钥**:向 `credentials` 服务要一次,只发给**这个提供方自己的 baseURL**,
|
|
10
|
+
* 不进日志、不回浏览器。
|
|
11
|
+
* 4. **缓存**:按提供方缓存一分钟 —— 余额胶囊五秒级的轮询不能变成对中转站的炮击
|
|
12
|
+
* (中转站被轮询打脸会限流,那才是真的"查不到余额")。
|
|
13
|
+
*
|
|
14
|
+
* 所有失败路径都返回**带原因的空结果**,而不是抛异常:余额胶囊按"读不到就不画"的规矩
|
|
15
|
+
* 处理,插件其余部分不受影响。
|
|
16
|
+
*/
|
|
17
|
+
import { PROVIDER_CACHE_MS, factsFromSettings, failureOf, fetchProviderBalance, fetchUserBalance, } from './providers.js';
|
|
18
|
+
import { sessionById, sessionModelSelection } from './services.js';
|
|
19
|
+
/** 设置投影的缓存时长:用户改配置是低频动作,但改完应当很快生效。 */
|
|
20
|
+
export const FACTS_CACHE_MS = 5000;
|
|
21
|
+
/**
|
|
22
|
+
* 建一个"按会话查余额"的读取器(每个插件实例一个,自己持有缓存)。
|
|
23
|
+
*
|
|
24
|
+
* @param ctx - 宿主上下文(用 `get` 取 settings / credentials)。
|
|
25
|
+
* @param deps - 取数、时钟与中断信号(测试注入)。
|
|
26
|
+
* @returns 读一次余额的函数。
|
|
27
|
+
*/
|
|
28
|
+
export function createProviderBalanceReader(ctx, deps) {
|
|
29
|
+
/** 提供方 id → 最近一次结果(含"取不到"的原因,避免失败时也去炮击站点)。 */
|
|
30
|
+
var cache = new Map();
|
|
31
|
+
/** 设置投影缓存(`facts` 与取到的时刻)。 */
|
|
32
|
+
var factsCache = null;
|
|
33
|
+
/** 从设置读一次提供方连接事实(带短 TTL)。 */
|
|
34
|
+
function readFacts() {
|
|
35
|
+
var now = deps.now();
|
|
36
|
+
if (factsCache !== null && now - factsCache.at < FACTS_CACHE_MS)
|
|
37
|
+
return factsCache.facts;
|
|
38
|
+
var settings = serviceOf(ctx, 'settings');
|
|
39
|
+
var facts = new Map();
|
|
40
|
+
if (settings !== null) {
|
|
41
|
+
try {
|
|
42
|
+
// 只读 `baseURL` 与凭据引用名;关掉 secret 字段才拿得到的那些值我们一个都不碰。
|
|
43
|
+
var descriptors = settings.describe();
|
|
44
|
+
if (Array.isArray(descriptors))
|
|
45
|
+
facts = factsFromSettings(descriptors);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
facts = new Map();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
factsCache = { at: now, facts: facts };
|
|
52
|
+
return facts;
|
|
53
|
+
}
|
|
54
|
+
/** 按提供方的凭据引用名取一次密钥(取不到就是 `null`,不是错误)。 */
|
|
55
|
+
async function readKey(apiKeyEnv) {
|
|
56
|
+
var credentials = serviceOf(ctx, 'credentials');
|
|
57
|
+
if (credentials === null)
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
var hit = await credentials.resolve(apiKeyEnv);
|
|
61
|
+
if (!hit || typeof hit.value !== 'string' || hit.value === '')
|
|
62
|
+
return null;
|
|
63
|
+
return hit.value;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 问一次这个提供方的后台凭据是否已配。
|
|
71
|
+
*
|
|
72
|
+
* 只问 `describe`(不回值):客户端只需要知道"要不要弹设置面板",
|
|
73
|
+
* 把令牌发到浏览器毫无必要,也不该发。
|
|
74
|
+
*
|
|
75
|
+
* @param facts - 提供方事实(令牌引用名从里面取)。
|
|
76
|
+
* @returns 状态;这个提供方不可配令牌时回 `null`。
|
|
77
|
+
*/
|
|
78
|
+
async function tokenStateOf(facts) {
|
|
79
|
+
var ref = facts.accessTokenEnv;
|
|
80
|
+
if (typeof ref !== 'string' || ref === '')
|
|
81
|
+
return null;
|
|
82
|
+
var credentials = serviceOf(ctx, 'credentials');
|
|
83
|
+
if (credentials === null)
|
|
84
|
+
return { ref: ref, configured: false };
|
|
85
|
+
try {
|
|
86
|
+
if (typeof credentials.describe !== 'function') {
|
|
87
|
+
// 没有 `describe` 的旧实现:退回 `resolve` 看一眼有没有值(只要"有没有",不留值)。
|
|
88
|
+
var hit = await credentials.resolve(ref);
|
|
89
|
+
return { ref: ref, configured: !!hit && typeof hit.value === 'string' && hit.value !== '' };
|
|
90
|
+
}
|
|
91
|
+
var described = await credentials.describe(ref);
|
|
92
|
+
return { ref: ref, configured: described !== undefined && described.configured === true };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { ref: ref, configured: false };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 用 API Key 读一次"本 Key 的累计已用";没 Key 或读不到都回 `null`。
|
|
100
|
+
*
|
|
101
|
+
* @param facts - 提供方事实。
|
|
102
|
+
* @returns 这一路的结论;不可用回 `null`。
|
|
103
|
+
*/
|
|
104
|
+
async function readKeyValue(facts) {
|
|
105
|
+
var key = await readKey(facts.apiKeyEnv);
|
|
106
|
+
// 区分"根本没配 Key"与"配了但读不到":前者该报 `no-key`(用户得去配),
|
|
107
|
+
// 后者是站点的问题。合并成一个 `unsupported` 会把这两种处境混成一句话。
|
|
108
|
+
if (key === null)
|
|
109
|
+
return { status: 'no-key' };
|
|
110
|
+
var value = await fetchProviderBalance(facts, key, deps);
|
|
111
|
+
return value === null ? { status: 'failed' } : { status: 'value', value: value };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 把两条路的结论并成一份载荷。
|
|
115
|
+
*
|
|
116
|
+
* 合并规则:账号路的数**优先**(它是后台那个"当前余额"),但 Key 路的
|
|
117
|
+
* `usedKey` 照样带上 —— 两本账都要让人看见。只有两条路都空才算失败。
|
|
118
|
+
*
|
|
119
|
+
* @param provider - 提供方 id。
|
|
120
|
+
* @param kind - 官方还是中转站。
|
|
121
|
+
* @param account - 账号路的结论。
|
|
122
|
+
* @param key - Key 路的结论。
|
|
123
|
+
* @returns 合并后的结论。
|
|
124
|
+
*/
|
|
125
|
+
function mergeOutcomes(provider, kind, account, keyResult) {
|
|
126
|
+
var key = keyResult.status === 'value' ? keyResult.value : null;
|
|
127
|
+
if (account === null && key === null) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
failure: failureOf(provider, kind, keyResult.status === 'no-key' ? 'no-key' : 'unsupported'),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (account !== null && key === null)
|
|
134
|
+
return { ok: true, value: account };
|
|
135
|
+
if (account === null && key !== null)
|
|
136
|
+
return { ok: true, value: key };
|
|
137
|
+
// 两条都有:以账号路的读数为准,补上 Key 自己那份"已用"与 Key 路的占位标记。
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
value: {
|
|
141
|
+
provider: provider,
|
|
142
|
+
kind: kind,
|
|
143
|
+
currency: account.currency,
|
|
144
|
+
remaining: account.remaining,
|
|
145
|
+
usedAccount: account.usedAccount,
|
|
146
|
+
// 账号路没有这个数时,用 Key 路的顶上;两条路都有则**都留着**(下面的 usedKey)。
|
|
147
|
+
usedKey: key.usedKey,
|
|
148
|
+
requests: account.requests,
|
|
149
|
+
// 只要账号路拿到了真实余额,就不是"无限额度占位"。
|
|
150
|
+
placeholderLimit: account.remaining === null ? key.placeholderLimit : false,
|
|
151
|
+
wallets: account.wallets,
|
|
152
|
+
bonus: account.bonus,
|
|
153
|
+
at: Math.max(account.at, key.at),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 用网页访问令牌读一次**账号级**余额;没配令牌或读不到都回 `null`。
|
|
159
|
+
*
|
|
160
|
+
* 这条路是"有则更好":配了就能看到中转站后台那个"当前余额",
|
|
161
|
+
* 没配就安静退回用量那一路 —— 绝不能因为缺令牌而让胶囊整枚消失。
|
|
162
|
+
*
|
|
163
|
+
* @param facts - 提供方事实(含推导出来的令牌引用名)。
|
|
164
|
+
* @returns 账号级余额;不可用回 `null`。
|
|
165
|
+
*/
|
|
166
|
+
async function readAccountBalance(facts) {
|
|
167
|
+
var tokenEnv = facts.accessTokenEnv;
|
|
168
|
+
if (typeof tokenEnv !== 'string' || tokenEnv === '')
|
|
169
|
+
return null;
|
|
170
|
+
var accessToken = await readKey(tokenEnv);
|
|
171
|
+
if (accessToken === null)
|
|
172
|
+
return null;
|
|
173
|
+
var userId = typeof facts.userIdEnv === 'string' ? await readKey(facts.userIdEnv) : null;
|
|
174
|
+
return fetchUserBalance(facts, accessToken, userId, deps);
|
|
175
|
+
}
|
|
176
|
+
return async function readProviderBalance(sessionId, force) {
|
|
177
|
+
var session = sessionById(sessionId);
|
|
178
|
+
if (session === null)
|
|
179
|
+
return { ok: false, failure: failureOf(null, null, 'no-session') };
|
|
180
|
+
var selection = sessionModelSelection(session);
|
|
181
|
+
if (selection === null)
|
|
182
|
+
return { ok: false, failure: failureOf(null, null, 'no-provider') };
|
|
183
|
+
var provider = selection.provider;
|
|
184
|
+
// DeepSeek 平台账号:客户端已经用 `remote.account.getBalance` 拿到同一份数,
|
|
185
|
+
// 宿主不再走一遍(两条路径取同一个数只会多一处漂移)。
|
|
186
|
+
if (provider === 'deepseek-account') {
|
|
187
|
+
return { ok: true, value: null, account: true };
|
|
188
|
+
}
|
|
189
|
+
// `force` = 用户点了胶囊:跳过缓存。否则"我刚配好令牌,怎么还是旧数"这件事
|
|
190
|
+
// 会持续一分钟 —— 而点胶囊本身就是"我怀疑这个数是旧的"那个动作。
|
|
191
|
+
var cached = cache.get(provider);
|
|
192
|
+
if (force !== true && cached !== undefined && deps.now() - cached.at < PROVIDER_CACHE_MS) {
|
|
193
|
+
// token 状态**不进缓存**:刚配好令牌的那一刻它就该变,等一分钟是没法解释的行为。
|
|
194
|
+
return await withToken(cached.outcome, cached.facts);
|
|
195
|
+
}
|
|
196
|
+
var known = readFacts();
|
|
197
|
+
var facts = known.get(provider);
|
|
198
|
+
if (facts === undefined) {
|
|
199
|
+
// 没有连接事实就不知道它的令牌引用名,也就没有 token 状态可给 —— 直接回,
|
|
200
|
+
// 也不进缓存(设置投影可能只是还没就绪,缓存一分钟反而延长了错误的寿命)。
|
|
201
|
+
// 把"我们实际认出了哪些提供方"带在 message 里:这条消息不会画到界面上,
|
|
202
|
+
// 但会进客户端 probe 日志 —— 下次有人问"为什么只显示一个 `—`",一眼就知道
|
|
203
|
+
// 是设置投影没读到(known 空)、还是 id 对不上(known 里有别的)。
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
failure: failureOf(provider, null, 'unknown-provider', 'known providers: ' + (known.size === 0 ? '(none)' : [...known.keys()].join(', '))),
|
|
207
|
+
token: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// 两条路**都走一遍再合并**,不是二选一:它们给的是两本不同的账。
|
|
211
|
+
// - 账号路(要后台凭据):剩余余额 + 账号累计已用 + 请求数;
|
|
212
|
+
// - Key 路(API Key):本 Key 累计已用。
|
|
213
|
+
// 站点上这两个"已用"能同时读到(ur:$526.66 vs $479.44),合并后悬停里两个都列出来。
|
|
214
|
+
var accountValue = await readAccountBalance(facts);
|
|
215
|
+
var keyValue = await readKeyValue(facts);
|
|
216
|
+
var outcome = mergeOutcomes(provider, facts.kind, accountValue, keyValue);
|
|
217
|
+
cache.set(provider, { at: deps.now(), outcome: outcome, facts: facts });
|
|
218
|
+
return await withToken(outcome, facts);
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* 给结论补上"这个提供方的后台凭据配了没"。
|
|
222
|
+
*
|
|
223
|
+
* @param outcome - 余额结论(缓存里的那份,不含 token 状态)。
|
|
224
|
+
* @param facts - 提供方事实。
|
|
225
|
+
* @returns 带 token 状态的新结论(不改缓存里那份)。
|
|
226
|
+
*/
|
|
227
|
+
async function withToken(outcome, facts) {
|
|
228
|
+
var token = await tokenStateOf(facts);
|
|
229
|
+
return { ...outcome, token: token };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/* ------------------------------------------------ 写后台凭据(给胶囊上的设置面板用) */
|
|
233
|
+
/** 令牌长度上限:PAT 通常几十字节,4096 已经宽松到不可能误伤,又能挡住误粘贴整页文本。 */
|
|
234
|
+
export const PROVIDER_TOKEN_MAX = 4096;
|
|
235
|
+
/**
|
|
236
|
+
* 建一个"把后台凭据写进凭据存储"的写入器(供胶囊上的设置面板调用)。
|
|
237
|
+
*
|
|
238
|
+
* 为什么让插件自己写、而不是让用户去编辑 `~/.dsh/.credentials.yaml`:
|
|
239
|
+
* 那个文件是 DSH 的凭据库,让用户手工改它等于把"配一个令牌"变成"改一个配置文件"——
|
|
240
|
+
* 手滑一个缩进就整份凭据读不出来。这套 UI 只写**一个**引用、且是**推导出来的**那个。
|
|
241
|
+
*
|
|
242
|
+
* 安全边界(三条都必须守):
|
|
243
|
+
* 1. 只写 `facts.accessTokenEnv`(即 `*_ACCESS_TOKEN`),不接受任意引用名 ——
|
|
244
|
+
* 否则这就是一个"往凭据库里任意写入"的接口;
|
|
245
|
+
* 2. 令牌不回显、不进日志、不缓存;
|
|
246
|
+
* 3. 写之前把站点 / 长度 / 换行都校验一遍,脏数据宁可不写。
|
|
247
|
+
*
|
|
248
|
+
* @param ctx - 宿主上下文(用 `get` 取 settings / credentials)。
|
|
249
|
+
* @returns 写一次令牌的函数。
|
|
250
|
+
*/
|
|
251
|
+
export function createProviderTokenWriter(ctx) {
|
|
252
|
+
/** 从设置读一次提供方连接事实(写是低频动作,不缓存,让它立刻看到最新投影)。 */
|
|
253
|
+
function factsOf(provider) {
|
|
254
|
+
var settings = serviceOf(ctx, 'settings');
|
|
255
|
+
if (settings === null)
|
|
256
|
+
return null;
|
|
257
|
+
try {
|
|
258
|
+
var descriptors = settings.describe();
|
|
259
|
+
if (!Array.isArray(descriptors))
|
|
260
|
+
return null;
|
|
261
|
+
return factsFromSettings(descriptors).get(provider) ?? null;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return async function writeProviderToken(provider, token) {
|
|
268
|
+
if (typeof token !== 'string')
|
|
269
|
+
return { ok: false, reason: 'bad-token' };
|
|
270
|
+
// 去空白是必须的:从浏览器复制令牌十次有八次带一个尾空格或换行。
|
|
271
|
+
var value = token.trim();
|
|
272
|
+
if (value === '' || value.length > PROVIDER_TOKEN_MAX || /\s/.test(value)) {
|
|
273
|
+
return { ok: false, reason: 'bad-token' };
|
|
274
|
+
}
|
|
275
|
+
var facts = factsOf(provider);
|
|
276
|
+
if (facts === null)
|
|
277
|
+
return { ok: false, reason: 'unknown-provider' };
|
|
278
|
+
var ref = facts.accessTokenEnv;
|
|
279
|
+
// 形状不认识的引用名一律不写:这条接口只服务"后台凭据"这一件事。
|
|
280
|
+
if (typeof ref !== 'string' || !ref.endsWith('_ACCESS_TOKEN')) {
|
|
281
|
+
return { ok: false, reason: 'not-supported' };
|
|
282
|
+
}
|
|
283
|
+
var credentials = serviceOf(ctx, 'credentials');
|
|
284
|
+
if (credentials === null || typeof credentials.set !== 'function') {
|
|
285
|
+
return { ok: false, reason: 'no-credentials' };
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
await credentials.set(ref, value);
|
|
289
|
+
return { ok: true, ref: ref };
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
reason: 'failed',
|
|
295
|
+
message: error instanceof Error ? error.message : String(error),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** 从 ctx 取一个服务;类型对不上就当没有(软依赖,缺了安静降级)。 */
|
|
301
|
+
function serviceOf(ctx, name) {
|
|
302
|
+
try {
|
|
303
|
+
var service = ctx.get(name);
|
|
304
|
+
if (service === undefined || service === null || typeof service !== 'object')
|
|
305
|
+
return null;
|
|
306
|
+
return service;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|