@bolloon/bolloon-agent 0.4.17 → 0.4.19
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/dist/agents/agent-registry.js +24 -0
- package/dist/agents/gateway-network.js +192 -14
- package/dist/agents/network-link.js +33 -0
- package/dist/cli/content.js +10 -0
- package/dist/cli/ink-app.js +131 -70
- package/dist/cli/keymap.js +36 -0
- package/dist/cli/loading-tui.js +11 -8
- package/dist/cli/markdown.js +18 -0
- package/dist/cli/stores.js +64 -0
- package/dist/cli/theme.js +23 -0
- package/dist/cli/timing.js +8 -0
- package/dist/cli/widget-host.js +68 -0
- package/dist/index.js +243 -12
- package/dist/web/mobile-agent.js +6 -0
- package/dist/web/mobile-core.js +12533 -149
- package/dist/web/mobile-gateway.js +191 -0
- package/dist/web/mobile.html +7 -0
- package/dist/web/mobile.js +52 -1
- package/dist/web/qr.js +49 -0
- package/package.json +4 -1
|
@@ -13,6 +13,8 @@ import * as path from 'path';
|
|
|
13
13
|
import { getCIDDatabase } from '../orbitdb/cid-database.js';
|
|
14
14
|
const home = () => process.env.HOME || os.homedir() || '/tmp';
|
|
15
15
|
export const REGISTRY_ORBIT_KEY = 'services';
|
|
16
|
+
/** 网络启动包 meta 键 (networkId/name/version/capacityOfMembers/sharedContextCid) */
|
|
17
|
+
export const REGISTRY_ORBIT_META_KEY = 'meta';
|
|
16
18
|
/** OrbitDB store 名 (按 did 短名确定性) */
|
|
17
19
|
export function registryStoreName(did) {
|
|
18
20
|
const short = String(did || 'local').replace(/[^a-zA-Z0-9-]/g, '_').slice(0, 40) || 'local';
|
|
@@ -118,6 +120,28 @@ export class OrbitDBAgentRegistry {
|
|
|
118
120
|
return orbit;
|
|
119
121
|
return this.loadLocalFromDisk();
|
|
120
122
|
}
|
|
123
|
+
async writeMeta(meta) {
|
|
124
|
+
if (!this.ready || !this._store)
|
|
125
|
+
return { ok: false, error: 'registry store 未就绪 (离线模式)' };
|
|
126
|
+
try {
|
|
127
|
+
await this._store.put(REGISTRY_ORBIT_META_KEY, meta);
|
|
128
|
+
return { ok: true };
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return { ok: false, error: String(e?.message || e).slice(0, 120) };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async readMeta() {
|
|
135
|
+
if (!this.ready || !this._store)
|
|
136
|
+
return null;
|
|
137
|
+
try {
|
|
138
|
+
const v = await this._store.get(REGISTRY_ORBIT_META_KEY);
|
|
139
|
+
return v && typeof v === 'object' ? v : null;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
121
145
|
async discover(query) {
|
|
122
146
|
const all = await this.list();
|
|
123
147
|
const q = String(query || '').trim().toLowerCase();
|
|
@@ -182,30 +182,34 @@ export async function joinNetwork(link, deps) {
|
|
|
182
182
|
// 合并到本地 registry (按 agentId + service.name 去重)
|
|
183
183
|
const registry = deps?.registry ?? getAgentRegistry();
|
|
184
184
|
const local = await registry.list();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const exists = local.some((l) => l.agentId === svc.agentId && l.service?.name === svc.service?.name);
|
|
190
|
-
if (!exists) {
|
|
185
|
+
const remoteServices = Array.isArray(remote) ? remote : [];
|
|
186
|
+
const { merged, joined } = mergeRemoteServices(remoteServices, local);
|
|
187
|
+
for (const svc of merged) {
|
|
188
|
+
if (!local.some((l) => l.agentId === svc.agentId && l.service?.name === svc.service?.name)) {
|
|
191
189
|
await registry.register(svc).catch(() => { });
|
|
192
|
-
joined++;
|
|
193
190
|
}
|
|
194
191
|
}
|
|
195
|
-
//
|
|
192
|
+
// ① 网络启动包 (meta): 拉网络声明 (networkId/名称/版本/容量/共享context CID), 尽力而为
|
|
193
|
+
const meta = await fetchNetworkMeta(parsed).catch(() => null);
|
|
194
|
+
const bootstrap = buildNetworkBootstrap(meta);
|
|
195
|
+
// 记录成员身份 (持久化 → 重启后自动恢复) + 网络启动包信息
|
|
196
196
|
await saveNetworks([
|
|
197
197
|
...existing,
|
|
198
198
|
{
|
|
199
199
|
link: norm,
|
|
200
200
|
linkKey: identityKey,
|
|
201
201
|
kind: parsed.kind,
|
|
202
|
-
name: parsed.networkName,
|
|
202
|
+
name: bootstrap.name || parsed.networkName,
|
|
203
203
|
joinedAt: new Date().toISOString(),
|
|
204
|
-
serviceCount:
|
|
204
|
+
serviceCount: remoteServices.length,
|
|
205
205
|
lastSyncAt: new Date().toISOString(),
|
|
206
|
+
networkId: bootstrap.networkId,
|
|
207
|
+
version: bootstrap.version,
|
|
208
|
+
capacityOfMembers: bootstrap.capacityOfMembers,
|
|
209
|
+
sharedContextCid: bootstrap.sharedContextCid,
|
|
206
210
|
},
|
|
207
211
|
]);
|
|
208
|
-
return { ok: true, joined, total:
|
|
212
|
+
return { ok: true, joined, total: remoteServices.length, linkKind: parsed.kind, networkName: bootstrap.name || parsed.networkName, networkId: bootstrap.networkId, sharedContextCid: bootstrap.sharedContextCid };
|
|
209
213
|
}
|
|
210
214
|
/** 启动恢复: 重拉所有已加入网络 (失败静默, 保留记录). 返回恢复统计. */
|
|
211
215
|
export async function restoreJoinedNetworks() {
|
|
@@ -228,6 +232,156 @@ export async function restoreJoinedNetworks() {
|
|
|
228
232
|
}
|
|
229
233
|
return { restored, failed, total: nets.length };
|
|
230
234
|
}
|
|
235
|
+
/** 把任意 meta 对象规整成 NetworkBootstrap (纯函数, 容错) */
|
|
236
|
+
export function buildNetworkBootstrap(meta) {
|
|
237
|
+
const m = meta && typeof meta === 'object' ? meta : {};
|
|
238
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
239
|
+
return {
|
|
240
|
+
networkId: str(m.networkId),
|
|
241
|
+
name: str(m.name),
|
|
242
|
+
version: str(m.version),
|
|
243
|
+
capacityOfMembers: typeof m.capacityOfMembers === 'number' ? m.capacityOfMembers : undefined,
|
|
244
|
+
sharedContextCid: str(m.sharedContextCid),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** 拉取网络 meta (尽力而为, 失败返回 null): orbitdb('meta' 键) / ipns(network.json) / http(doc.meta) */
|
|
248
|
+
async function fetchNetworkMeta(parsed) {
|
|
249
|
+
try {
|
|
250
|
+
if (parsed.kind === 'http') {
|
|
251
|
+
const r = await fetch(parsed.url, { signal: AbortSignal.timeout(15000) });
|
|
252
|
+
if (!r.ok)
|
|
253
|
+
return null;
|
|
254
|
+
const d = await r.json();
|
|
255
|
+
return d?.meta ?? null;
|
|
256
|
+
}
|
|
257
|
+
if (parsed.kind === 'ipns') {
|
|
258
|
+
const r = await fetch(`http://127.0.0.1:8080/ipns/${parsed.name}/network.json`, { signal: AbortSignal.timeout(15000) });
|
|
259
|
+
if (!r.ok)
|
|
260
|
+
return null;
|
|
261
|
+
const d = await r.json();
|
|
262
|
+
return d?.meta ?? d;
|
|
263
|
+
}
|
|
264
|
+
if (parsed.kind === 'orbitdb') {
|
|
265
|
+
const { getCIDDatabase } = await import('../orbitdb/cid-database.js');
|
|
266
|
+
const db = getCIDDatabase();
|
|
267
|
+
const store = await db.openStoreByAddress(parsed.address, 'keyvalue');
|
|
268
|
+
if (!store)
|
|
269
|
+
return null;
|
|
270
|
+
const v = await store.get('meta').catch(() => null);
|
|
271
|
+
return v ?? null;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** 纯函数: 远端服务并入本地 (按 agentId + service.name 去重), 返回合并结果 + 新增数 */
|
|
280
|
+
export function mergeRemoteServices(remote, local) {
|
|
281
|
+
const out = local.slice();
|
|
282
|
+
let joined = 0;
|
|
283
|
+
for (const svc of remote) {
|
|
284
|
+
if (!svc?.agentId || !svc?.service?.name)
|
|
285
|
+
continue;
|
|
286
|
+
const exists = out.some((l) => l.agentId === svc.agentId && l.service?.name === svc.service?.name);
|
|
287
|
+
if (!exists) {
|
|
288
|
+
out.push(svc);
|
|
289
|
+
joined++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { merged: out, joined };
|
|
293
|
+
}
|
|
294
|
+
/** ② 拉取网络画像 (只读, 不落盘): 供 on-join 告知 agent "网络里有什么" */
|
|
295
|
+
export async function pullNetworkProfile(link) {
|
|
296
|
+
const parsed = parseNetworkLink(link);
|
|
297
|
+
if (!parsed)
|
|
298
|
+
return null;
|
|
299
|
+
let services = null;
|
|
300
|
+
if (parsed.kind === 'http')
|
|
301
|
+
services = await fetchRemoteRegistry(parsed.url);
|
|
302
|
+
else if (parsed.kind === 'ipns')
|
|
303
|
+
services = await fetchIpnsRegistry(parsed.name);
|
|
304
|
+
else if (parsed.kind === 'orbitdb')
|
|
305
|
+
services = await fetchOrbitdbRegistry(parsed.address);
|
|
306
|
+
const meta = await fetchNetworkMeta(parsed).catch(() => null);
|
|
307
|
+
if (!services || services.length === 0)
|
|
308
|
+
return null;
|
|
309
|
+
return { bootstrap: buildNetworkBootstrap(meta), members: services };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* ③ 成员自描述广播: 把本机服务声明(list .agentId 匹配 self)写回共享网络 store ('services' 合并 self).
|
|
313
|
+
* ① ipns/http 无回写 → 只本地登记 (返回 note); ② orbitdb replica 只读时写穿失败 → 非致命 (返回 note).
|
|
314
|
+
* opts.members: 本机要广播的成员声明 (通常 = 自己注册的 AgentService 列表).
|
|
315
|
+
*/
|
|
316
|
+
export async function networkShareSelf(link, members, opts) {
|
|
317
|
+
if (!members || members.length === 0)
|
|
318
|
+
return { ok: true, note: '无本机成员声明可广播' };
|
|
319
|
+
const parsed = parseNetworkLink(link);
|
|
320
|
+
if (!parsed)
|
|
321
|
+
return { ok: false, error: '链接无法解析' };
|
|
322
|
+
if (parsed.kind !== 'orbitdb') {
|
|
323
|
+
return { ok: true, note: `${parsed.kind} 无回写能力, 已本地登记 (发起方自托管 registry 应含本机)` };
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const { getCIDDatabase } = await import('../orbitdb/cid-database.js');
|
|
327
|
+
const db = getCIDDatabase();
|
|
328
|
+
const store = await db.openStoreByAddress(parsed.address, 'keyvalue');
|
|
329
|
+
if (!store)
|
|
330
|
+
return { ok: false, error: '网络 store 不可达' };
|
|
331
|
+
const cur = (await store.get('services').catch(() => null));
|
|
332
|
+
const arr = Array.isArray(cur) ? cur.slice() : [];
|
|
333
|
+
for (const m of members) {
|
|
334
|
+
const i = arr.findIndex((s) => s.agentId === m.agentId && s.service?.name === m.service?.name);
|
|
335
|
+
const entry = { ...m, updatedAt: new Date().toISOString(), registeredAt: m.registeredAt || new Date().toISOString() };
|
|
336
|
+
if (i >= 0)
|
|
337
|
+
arr[i] = entry;
|
|
338
|
+
else
|
|
339
|
+
arr.push(entry);
|
|
340
|
+
}
|
|
341
|
+
await store.put('services', arr);
|
|
342
|
+
return { ok: true, note: `已广播 ${members.length} 条本机声明到网络` };
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
return { ok: false, error: `写穿失败(疑似只读 replica): ${String(e?.message || e).slice(0, 120)}`, note: '已在本地登记, 可在你的分享链接中带上本机' };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* ① 拉取共享 context (由启动包 sharedContextCid 指向): 从本地 IPFS 网关读文本返回,
|
|
350
|
+
* 调用方负责注入本地 context (了解"所有信息"的最后一段).
|
|
351
|
+
*/
|
|
352
|
+
export async function pullNetworkSharedContext(cid) {
|
|
353
|
+
try {
|
|
354
|
+
const r = await fetch(`http://127.0.0.1:8080/ipfs/${cid}`, { signal: AbortSignal.timeout(15000) });
|
|
355
|
+
if (!r.ok)
|
|
356
|
+
return null;
|
|
357
|
+
return await r.text();
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* ① 发布共享 context 到 OrbitDB (内容寻址), 返回 CID — 供 shareNetworkLink 的 sharedContextCid,
|
|
365
|
+
* 手机/PC 入网后拉取即"近期上下文同步". opts.share=true 时把块放入 helia blockstore 供网络拉取.
|
|
366
|
+
*/
|
|
367
|
+
export async function publishNetworkSharedContext(text, opts) {
|
|
368
|
+
try {
|
|
369
|
+
const { getCIDDatabase } = await import('../orbitdb/cid-database.js');
|
|
370
|
+
const db = getCIDDatabase();
|
|
371
|
+
const rec = await db.save({ agentId: opts?.agentId || 'bolloon-network', type: 'context', content: text });
|
|
372
|
+
let cid = rec.id;
|
|
373
|
+
if (opts?.share !== false) {
|
|
374
|
+
try {
|
|
375
|
+
cid = await db.share(cid);
|
|
376
|
+
}
|
|
377
|
+
catch { /* 分享失败仍可用 CID 读 */ }
|
|
378
|
+
}
|
|
379
|
+
return { ok: true, cid };
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
return { ok: false, error: String(e?.message || e).slice(0, 140) };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
231
385
|
// ============ 分享链接 ============
|
|
232
386
|
/**
|
|
233
387
|
* 生成本机可分享的网络链接: orbitdb://<registry storeAddress>?name=<网络名>.
|
|
@@ -244,8 +398,24 @@ export async function shareNetworkLink(opts) {
|
|
|
244
398
|
if (!registry.ready || !registry.storeAddress) {
|
|
245
399
|
return { ok: false, error: 'OrbitDB registry 未就绪 (离线模式). 备选: 把 registry 列表发布成 https://.../registry 端点分享' };
|
|
246
400
|
}
|
|
247
|
-
|
|
248
|
-
|
|
401
|
+
// ① 全量启动包: 把 networkId/名称/版本/容量/共享context CID 写进共享 store (尽力而为, 失败仍分享)
|
|
402
|
+
const networkId = opts?.meta?.networkId || registry.storeName;
|
|
403
|
+
const name = String(opts?.name || opts?.meta?.name || registry.storeName || 'bolloon-network');
|
|
404
|
+
const metaObj = {
|
|
405
|
+
networkId,
|
|
406
|
+
name,
|
|
407
|
+
version: opts?.meta?.version,
|
|
408
|
+
capacityOfMembers: opts?.meta?.capacityOfMembers,
|
|
409
|
+
sharedContextCid: opts?.meta?.sharedContextCid,
|
|
410
|
+
};
|
|
411
|
+
let note;
|
|
412
|
+
if (registry.writeMeta) {
|
|
413
|
+
const w = await registry.writeMeta(metaObj).catch(() => ({ ok: false, error: 'writeMeta 异常' }));
|
|
414
|
+
if (!w.ok)
|
|
415
|
+
note = `meta 写共享库失败 (${w.error || '未知'}) — 读方仍可拉到服务, 但拿不到启动包`;
|
|
416
|
+
}
|
|
417
|
+
const encodedName = encodeURIComponent(name);
|
|
418
|
+
return { ok: true, link: `orbitdb://${registry.storeAddress}?name=${encodedName}`, note };
|
|
249
419
|
}
|
|
250
420
|
catch (e) {
|
|
251
421
|
return { ok: false, error: `生成分享链接失败: ${String(e?.message || e).slice(0, 160)}` };
|
|
@@ -268,7 +438,15 @@ export async function maybeAutoJoinGateway(text, deps) {
|
|
|
268
438
|
return null; // 已在网络, 静默
|
|
269
439
|
if (r.ok) {
|
|
270
440
|
const netName = r.networkName ? `「${r.networkName}」` : ''; // URLSearchParams 已 decode
|
|
271
|
-
|
|
441
|
+
// ② on-join 广播: 写入自己的服务声明 (orbitdb 可回写时), 非致命
|
|
442
|
+
let selfNote = '';
|
|
443
|
+
if (deps?.self && deps.self.length > 0) {
|
|
444
|
+
const s = await networkShareSelf(link, deps.self, deps).catch(() => null);
|
|
445
|
+
selfNote = s?.ok ? ' · 已广播本机声明' : (s?.note ? ` · ${s.note}` : ' · 本机声明广播失败');
|
|
446
|
+
}
|
|
447
|
+
const bootPart = r.networkId ? ` (net=${String(r.networkId).slice(0, 16)})` : '';
|
|
448
|
+
const ctxPart = r.sharedContextCid ? ` · 共享ctx:${String(r.sharedContextCid).slice(0, 12)}` : '';
|
|
449
|
+
return `🆕 已自动加入 Agent 网络${netName}${bootPart} (${r.linkKind}): 拉取 ${r.total} 个服务, 新增 ${r.joined} 个${ctxPart}${selfNote}。用 gateway_status 查看, gateway_call 调用网络里的服务。`;
|
|
272
450
|
}
|
|
273
451
|
return `⚠️ 检测到 Agent 网络链接 (${r.linkKind || 'unknown'}) 但加入失败: ${r.error}`;
|
|
274
452
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ─── 网络链接纯函数 (无 Node 依赖, 桌面/手机浏览器共用) ────────────────────
|
|
2
|
+
// parseNetworkLink / detectGatewayLink 从 gateway-network.ts 抽出,
|
|
3
|
+
// 供 browser 端 (mobile-gateway) 复用, 不拖 OrbitDB/fs 进 WebView.
|
|
4
|
+
/** 解析链接字符串 → ParsedNetworkLink (剥离 ?name= query) */
|
|
5
|
+
export function parseNetworkLink(link) {
|
|
6
|
+
const l = String(link || '').trim();
|
|
7
|
+
if (!l)
|
|
8
|
+
return null;
|
|
9
|
+
const [base, query] = l.split('?');
|
|
10
|
+
let networkName;
|
|
11
|
+
try {
|
|
12
|
+
networkName = query ? (new URLSearchParams(query).get('name') || undefined) : undefined;
|
|
13
|
+
}
|
|
14
|
+
catch { /* 忽略坏 query */ }
|
|
15
|
+
if (base.startsWith('ipns://'))
|
|
16
|
+
return { kind: 'ipns', value: base.slice('ipns://'.length), url: base, networkName };
|
|
17
|
+
if (base.startsWith('orbitdb://')) {
|
|
18
|
+
let address = base.slice('orbitdb://'.length);
|
|
19
|
+
if (!address.startsWith('/'))
|
|
20
|
+
address = `/${address}`;
|
|
21
|
+
return { kind: 'orbitdb', value: address, url: base, networkName };
|
|
22
|
+
}
|
|
23
|
+
if (base.startsWith('http://') || base.startsWith('https://'))
|
|
24
|
+
return { kind: 'http', value: base, url: base, networkName };
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/** 从消息文本检测 gateway 链接 (自动加入触发器用) */
|
|
28
|
+
export function detectGatewayLink(text) {
|
|
29
|
+
const t = String(text || '');
|
|
30
|
+
const re = /(orbitdb:\/\/\/?orbitdb\/[^\s)'"<>,。;]+|ipns:\/\/[^\s)'"<>,。;]+|https?:\/\/[^\s)'"<>,。;]*\/registry(?:\?[^\s)'"<>,。;]*)?)/;
|
|
31
|
+
const m = re.exec(t);
|
|
32
|
+
return m ? m[1].trim() : null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content.ts — TUI 文案/提示 与组件解耦 (内容层分离, 便于统一改文案/多语言)
|
|
3
|
+
*/
|
|
4
|
+
export const COMPOSER_PLACEHOLDER = '输入消息... @智能体 /命令 #文件 · Esc 双击退出 · /queue 排队 · !终端命令';
|
|
5
|
+
export const POPUP_TITLE_TAB = 'Tab 补齐';
|
|
6
|
+
export const POPUP_TITLE_AGENT = '@ 智能体';
|
|
7
|
+
export const POPUP_TITLE_FILE = '# 文件';
|
|
8
|
+
export const POPUP_TITLE_COMMAND = '/ 命令 · 技能 · 插件';
|
|
9
|
+
export const CHAR_EXIT_HINT = '再按一次 Esc 退出当前进程';
|
|
10
|
+
export const CHAR_LOADING = ['(`・ω・´)', '(´・_・`)', '(。•́︿•̀。)', 'ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ', '(◕‿◕)', 'ヽ(´▽`)/'];
|
package/dist/cli/ink-app.js
CHANGED
|
@@ -7,39 +7,45 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
* 2026-08-05: @ / # 弹出选择窗 — 输入 @ 命中智能体, / 命中命令+技能+插件, # 命中文件
|
|
8
8
|
* ↑/↓ 导航, Tab/Enter 选中, Esc 关闭, 弹出窗打开时 TextInput 让出焦点 (focus=false)
|
|
9
9
|
*/
|
|
10
|
-
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
11
|
-
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
10
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
11
|
+
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
12
12
|
import TextInput from 'ink-text-input';
|
|
13
|
-
import {
|
|
13
|
+
import { dispWidth, LOADING_FRAMES as KAOMOJI } from './loading-tui.js';
|
|
14
|
+
import { THEME, fg } from './theme.js';
|
|
15
|
+
import { COMPOSER_PLACEHOLDER, POPUP_TITLE_TAB, POPUP_TITLE_AGENT, POPUP_TITLE_FILE, POPUP_TITLE_COMMAND } from './content.js';
|
|
16
|
+
import { DOUBLE_ESC_MS, STATUS_TICK_MS, THINK_FRAME_MS } from './timing.js';
|
|
17
|
+
import { resolveNormalKey, applyScroll } from './keymap.js';
|
|
18
|
+
import { useWidgets } from './widget-host.js';
|
|
19
|
+
import { useStore, transcriptStore, uiStore, appendMsg, replaceLastMsg, replaceMarkerMsg, setUiStatus, setUiThinking, setUiTransient } from './stores.js';
|
|
14
20
|
import { loadAgents, loadCommands, loadSkills, loadPlugins, loadFiles, getMention, matchFileScore, } from './mention-data.js';
|
|
15
|
-
// ─── 组件: Logo Box ──────────────────────────────────────────────────────────
|
|
16
|
-
const LogoBox = ({ width }) => {
|
|
17
|
-
const art = brandArtLines();
|
|
18
|
-
const mw = Math.max(40, ...art.map(l => dispWidth(l))) + 4;
|
|
19
|
-
const bw = Math.min(width - 2, mw);
|
|
20
|
-
const rows = [boxTop('Bolloon Agent', bw)];
|
|
21
|
-
for (const l of art)
|
|
22
|
-
rows.push(boxRow(l, bw, 'center'));
|
|
23
|
-
rows.push(boxBottom(bw));
|
|
24
|
-
return (_jsx(Box, { flexDirection: "column", children: rows.map((r, i) => _jsx(Text, { children: r }, i)) }));
|
|
25
|
-
};
|
|
26
21
|
// ─── 组件: 消息列表 ──────────────────────────────────────────────────────────
|
|
27
|
-
|
|
22
|
+
/** 消息显示行数 (ANSI 剥离后按宽度 wrap 估行; 与 Ink 按父宽 wrap 近似一致) */
|
|
23
|
+
function msgVisualLines(text, width) {
|
|
24
|
+
let n = 0;
|
|
25
|
+
const clean = text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
26
|
+
for (const line of clean.split('\n')) {
|
|
27
|
+
const w = dispWidth(line);
|
|
28
|
+
n += Math.max(1, Math.ceil(w / Math.max(10, width - 1)));
|
|
29
|
+
}
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
// 2026-09-08: React.memo — msgs 引用不变时跳过重渲染 (配合虚拟化 slice, status tick 不再整表重绘)
|
|
33
|
+
const Messages = React.memo(({ msgs }) => (_jsx(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-start", children: msgs.map((m, i) => {
|
|
28
34
|
const clean = m.replace(/\x1b\[[0-9;]*m/g, '');
|
|
29
35
|
return clean.trim() ? _jsx(Text, { children: clean ? m : '' }, i) : null;
|
|
30
|
-
}) }));
|
|
36
|
+
}) })));
|
|
31
37
|
const MentionPopup = ({ title, items, sel, width, loading }) => {
|
|
32
38
|
const MAX_ROWS = 8;
|
|
33
39
|
// 2026-08-08: 滑动窗口 — 选中项始终可见 (原实现 fix 屏幕顶部, sel 超窗口时无高亮行)
|
|
34
40
|
const offset = Math.max(0, Math.min(sel - Math.floor(MAX_ROWS / 2), Math.max(0, items.length - MAX_ROWS)));
|
|
35
41
|
const shown = items.slice(offset, offset + MAX_ROWS);
|
|
36
42
|
const innerW = Math.max(width - 2, 10);
|
|
37
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { color:
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { color: THEME.accent, bold: true, children: `╭─ ${title} ${'─'.repeat(Math.max(2, innerW - dispWidth(title) - 4))}╮` }), loading && items.length === 0 ? (_jsx(Text, { color: "dim", children: "\u2502 \u626B\u63CF\u4E2D..." })) : !loading && items.length === 0 ? (_jsx(Text, { color: "dim", children: "\u2502 \u65E0\u5339\u914D" })) : (shown.map((it, i) => {
|
|
38
44
|
const active = i === sel;
|
|
39
45
|
const label = it.kind === 'file' ? it.label : `${it.kind === 'skill' ? '⚡' : it.kind === 'plugin' ? '🔌' : ''}${it.label}`;
|
|
40
46
|
const hint = it.hint ? `${it.hint}` : it.kind === 'file' ? '文件' : '';
|
|
41
47
|
return (_jsxs(Box, { width: innerW, children: [_jsx(Text, { color: active ? 'black' : undefined, backgroundColor: active ? 'cyan' : undefined, children: `${active ? '❯ ' : ' '}${label}` }), _jsx(Text, { color: active ? 'black' : 'dim', backgroundColor: active ? 'cyan' : undefined, dimColor: !active, children: ` ${hint}` })] }, `${it.kind}:${it.label}`));
|
|
42
|
-
})), items.length > MAX_ROWS && (_jsxs(Text, { color: "dim", children: ["\u2502 ", offset + 1, "-", offset + shown.length, "/", items.length, " \u00B7 \u8FD8\u6709 ", items.length - (offset + shown.length), " \u9879..."] })), _jsx(Text, { color:
|
|
48
|
+
})), items.length > MAX_ROWS && (_jsxs(Text, { color: "dim", children: ["\u2502 ", offset + 1, "-", offset + shown.length, "/", items.length, " \u00B7 \u8FD8\u6709 ", items.length - (offset + shown.length), " \u9879..."] })), _jsx(Text, { color: THEME.accent, children: `╰${'─'.repeat(innerW)}╯` })] }));
|
|
43
49
|
};
|
|
44
50
|
const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH }) => {
|
|
45
51
|
const [input, setInput] = useState('');
|
|
@@ -50,17 +56,23 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
50
56
|
}, [input]);
|
|
51
57
|
// 2026-08-07: 提交防重 (InkApp \n/\r 兜底 + TextInput 双触发场景)
|
|
52
58
|
const lastSubmitRef = useRef({ t: 0, v: '' });
|
|
53
|
-
const
|
|
54
|
-
const
|
|
59
|
+
const msgs = useStore(transcriptStore); // #2 状态外置: transcript 来自外部 store
|
|
60
|
+
const ui = useStore(uiStore); // #2 status/thinking/transient 外置
|
|
61
|
+
const status = ui.status || initialStatus;
|
|
62
|
+
const thinking = ui.thinking;
|
|
63
|
+
const transient = ui.transient;
|
|
64
|
+
// #8 占用槽: 右侧 rails (有 widget 占宽, 无则布局不变)
|
|
65
|
+
const widgets = useWidgets();
|
|
66
|
+
const railNames = Object.keys(widgets);
|
|
67
|
+
const hasRails = railNames.length > 0;
|
|
55
68
|
const { exit } = useApp();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const [transient, setTransient] = useState(null);
|
|
69
|
+
// 虚拟化滚动: 行窗口 top + 是否跟随底部 (用户上滚后自动跟随关闭, End 恢复)
|
|
70
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
71
|
+
const stickRef = useRef(true);
|
|
60
72
|
const thinkingIdx = useRef(0);
|
|
61
73
|
// 双击 Esc 退出当前进程 (500ms 窗口内第二次按下)
|
|
62
74
|
const lastEscRef = useRef(0);
|
|
63
|
-
const C_WARN_ANSI =
|
|
75
|
+
const C_WARN_ANSI = fg(THEME.warn); // #f59e0b
|
|
64
76
|
// ── @ / # 弹出窗状态 ──────────────────────────────────────────────────────
|
|
65
77
|
const mention = useMemo(() => getMention(input), [input]);
|
|
66
78
|
const mentionKey = mention ? `${mention.kind}:${mention.start}` : null;
|
|
@@ -179,10 +191,10 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
179
191
|
}, [items, mention, tabState]);
|
|
180
192
|
const popupOpen = !!(tabState || (mention && dismissed !== mentionKey));
|
|
181
193
|
const safeSel = Math.min(sel, Math.max(0, filtered.length - 1));
|
|
182
|
-
const popupTitle = tabState ?
|
|
183
|
-
: mention?.kind === 'agent' ?
|
|
184
|
-
: mention?.kind === 'file' ?
|
|
185
|
-
:
|
|
194
|
+
const popupTitle = tabState ? POPUP_TITLE_TAB
|
|
195
|
+
: mention?.kind === 'agent' ? POPUP_TITLE_AGENT
|
|
196
|
+
: mention?.kind === 'file' ? POPUP_TITLE_FILE
|
|
197
|
+
: POPUP_TITLE_COMMAND;
|
|
186
198
|
// 在指定 start 位置插入补齐文本 (函数式更新, 闭包安全)
|
|
187
199
|
const insertAt = useCallback((start, it) => {
|
|
188
200
|
setInput(cur => {
|
|
@@ -276,34 +288,52 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
276
288
|
}
|
|
277
289
|
}, [input, insertAt]);
|
|
278
290
|
const [tiKey, setTiKey] = useState(0);
|
|
291
|
+
// 2026-09-08 (Hermes TUI 学习落地): 实时终端尺寸 — 原来 terminalW/H 在 mount 时冻结,
|
|
292
|
+
// 终端 resize 后分隔线/logo 宽度全错位 (Hermes 用 resizeCoalescer + 实时 layout)。
|
|
293
|
+
// 订阅 stdout resize, 每次渲染用最新列数。
|
|
294
|
+
const { stdout } = useStdout();
|
|
295
|
+
const [termSize, setTermSize] = useState({ w: terminalW, h: terminalH });
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
// resizeCoalescer (#9): 拖拽终端时会连发 resize — 聚合到 80ms 空闲后一次性应用, 避免中间态跳帧
|
|
298
|
+
let raf = null;
|
|
299
|
+
let pending = null;
|
|
300
|
+
const apply = () => {
|
|
301
|
+
if (pending) {
|
|
302
|
+
const p = pending;
|
|
303
|
+
pending = null;
|
|
304
|
+
setTermSize(p);
|
|
305
|
+
}
|
|
306
|
+
raf = null;
|
|
307
|
+
};
|
|
308
|
+
const update = () => {
|
|
309
|
+
pending = { w: stdout?.columns || terminalW, h: stdout?.rows || terminalH };
|
|
310
|
+
if (raf)
|
|
311
|
+
clearTimeout(raf);
|
|
312
|
+
raf = setTimeout(apply, 80);
|
|
313
|
+
};
|
|
314
|
+
update();
|
|
315
|
+
stdout?.on?.('resize', update);
|
|
316
|
+
return () => { stdout?.removeListener?.('resize', update); if (raf)
|
|
317
|
+
clearTimeout(raf); };
|
|
318
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
319
|
+
}, [stdout]);
|
|
320
|
+
const W = termSize.w;
|
|
279
321
|
// 全局: 思考动画控制
|
|
280
322
|
useEffect(() => {
|
|
281
323
|
// 2026-08-06: 防御 — 某些环境 (tsx/完整 CLI 初始化) 下 stdin 会处于 paused,
|
|
282
324
|
// 不恢复则 useInput 收不到任何输入 (实测 isPaused=true, listeners=0)
|
|
283
325
|
if (process.stdin.isPaused())
|
|
284
326
|
process.stdin.resume();
|
|
285
|
-
globalThis.__inkSetThinking = (v) =>
|
|
286
|
-
globalThis.__inkAppend = (line) =>
|
|
287
|
-
|
|
288
|
-
};
|
|
289
|
-
globalThis.__inkSetStatus = (s) => {
|
|
290
|
-
setStatus(s);
|
|
291
|
-
};
|
|
327
|
+
globalThis.__inkSetThinking = (v) => setUiThinking(v);
|
|
328
|
+
globalThis.__inkAppend = (line) => appendMsg(line);
|
|
329
|
+
globalThis.__inkSetStatus = (s) => setUiStatus(s);
|
|
292
330
|
// 2026-08-10: 临时状态行 (自动整理/经验整理): 传字符串显示, 传 null 清空 (显示为空)
|
|
293
|
-
globalThis.__inkSetTransient = (v) =>
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// 2026-08
|
|
297
|
-
//
|
|
298
|
-
globalThis.
|
|
299
|
-
setMsgs(prev => {
|
|
300
|
-
if (prev.length === 0)
|
|
301
|
-
return [...prev, line];
|
|
302
|
-
const next = prev.slice();
|
|
303
|
-
next[next.length - 1] = line;
|
|
304
|
-
return next;
|
|
305
|
-
});
|
|
306
|
-
};
|
|
331
|
+
globalThis.__inkSetTransient = (v) => setUiTransient(v);
|
|
332
|
+
// 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 → 完成态用). 不命中则追加.
|
|
333
|
+
globalThis.__inkReplaceLast = (line) => replaceLastMsg(line);
|
|
334
|
+
// 2026-09-08: 按内容匹配替换 — 占位框可能在 P2P/连接消息之后才被替换,
|
|
335
|
+
// __inkReplaceLast 会覆盖错一条; 用字符串标记精确定位要替换的消息.
|
|
336
|
+
globalThis.__inkReplaceMatching = (marker, line) => replaceMarkerMsg(marker, line);
|
|
307
337
|
return () => {
|
|
308
338
|
delete globalThis.__inkAppend;
|
|
309
339
|
delete globalThis.__inkSetStatus;
|
|
@@ -443,6 +473,18 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
443
473
|
return; // 其余键忽略 (return/tab/esc 等由 TextInput 或上层处理)
|
|
444
474
|
}
|
|
445
475
|
// ── 正常模式 ──
|
|
476
|
+
// #7 输入层: 滚动键走数据化 keymap (Ctrl+U/D / PgUp/PgDn / Home-End / Alt+↑↓ / Ctrl+Home-End)
|
|
477
|
+
if (totalLines > availH) {
|
|
478
|
+
const pg = Math.max(6, availH - 2);
|
|
479
|
+
const maxT = Math.max(0, totalLines - availH);
|
|
480
|
+
const act = resolveNormalKey(key, { scrollable: true, input: _input });
|
|
481
|
+
if (act !== 'none') {
|
|
482
|
+
const r = applyScroll(act, stickRef.current ? maxT : (scrollTop || 0), pg, maxT);
|
|
483
|
+
stickRef.current = r.stick;
|
|
484
|
+
setScrollTop(r.next);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
446
488
|
// 2026-08-07: Enter 兜底 — pty/管道下 termios 可能把 \r 转 \n 且 node 把整 chunk
|
|
447
489
|
// 当一次 keypress (key.return=false), TextInput 的 onSubmit 永不触发 → 消息发不出去.
|
|
448
490
|
// 应用层把 \n/\r 一律视为提交 (兼容 raw/cooked 两种模式, 不依赖 termios).
|
|
@@ -488,7 +530,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
488
530
|
// 双击 Esc 退出当前进程: 第一击提示, 500ms 内第二击退出
|
|
489
531
|
if (key.escape) {
|
|
490
532
|
const now = Date.now();
|
|
491
|
-
if (now - lastEscRef.current <
|
|
533
|
+
if (now - lastEscRef.current < DOUBLE_ESC_MS) {
|
|
492
534
|
requestExit();
|
|
493
535
|
}
|
|
494
536
|
else {
|
|
@@ -522,17 +564,17 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
522
564
|
try {
|
|
523
565
|
const s0 = getStatusUpdate();
|
|
524
566
|
if (s0)
|
|
525
|
-
|
|
567
|
+
setUiStatus(s0);
|
|
526
568
|
}
|
|
527
569
|
catch { /* 状态栏更新失败不致命 */ }
|
|
528
570
|
const timer = setInterval(() => {
|
|
529
571
|
try {
|
|
530
572
|
const s = getStatusUpdate();
|
|
531
573
|
if (s)
|
|
532
|
-
|
|
574
|
+
setUiStatus(s);
|
|
533
575
|
}
|
|
534
576
|
catch { /* 状态栏更新失败不致命 */ }
|
|
535
|
-
},
|
|
577
|
+
}, STATUS_TICK_MS);
|
|
536
578
|
return () => clearInterval(timer);
|
|
537
579
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
538
580
|
}, []);
|
|
@@ -549,17 +591,40 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
549
591
|
items: filtered.slice(0, 5).map(i => i.kind + ':' + i.label),
|
|
550
592
|
});
|
|
551
593
|
}, [popupOpen, mentionKey, filtered]);
|
|
552
|
-
// 思考动画 — kaomoji 旋转
|
|
553
|
-
const KAOMOJI = ['(`・ω・´)', '(´・_・`)', '(。•́︿•̀。)', 'ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ', '(◕‿◕)'];
|
|
594
|
+
// 思考动画 — kaomoji 旋转 (帧序列单一来源: loading-tui LOADING_FRAMES, 2026-09-08)
|
|
554
595
|
useEffect(() => {
|
|
555
596
|
if (!thinking)
|
|
556
597
|
return;
|
|
557
598
|
const timer = setInterval(() => {
|
|
558
599
|
thinkingIdx.current = (thinkingIdx.current + 1) % KAOMOJI.length;
|
|
559
|
-
},
|
|
600
|
+
}, THINK_FRAME_MS);
|
|
560
601
|
return () => clearInterval(timer);
|
|
561
602
|
}, [thinking]);
|
|
562
|
-
|
|
603
|
+
// ── 虚拟化 transcript (消息级窗口 + 自动跟随底部) ───────────────────────────
|
|
604
|
+
// #1 分区预留: chrome 固定行 = 分隔线×3 + 状态栏 + 输入栏 = 5; transcript 严格不超区
|
|
605
|
+
// (content Box flexGrow=1 但内容超宽会把 status/composer 挤走 → 可见窗口行数钳制 ≤ availH)
|
|
606
|
+
const availH = Math.max(6, termSize.h - 5);
|
|
607
|
+
const heights = useMemo(() => msgs.map(m => msgVisualLines(m, W)), [msgs, W]);
|
|
608
|
+
const cumulative = useMemo(() => {
|
|
609
|
+
const c = [0];
|
|
610
|
+
for (const h of heights)
|
|
611
|
+
c.push(c[c.length - 1] + h);
|
|
612
|
+
return c;
|
|
613
|
+
}, [heights]);
|
|
614
|
+
const totalLines = cumulative[cumulative.length - 1] || 0;
|
|
615
|
+
const maxTop = Math.max(0, totalLines - availH);
|
|
616
|
+
const sticky = stickRef.current;
|
|
617
|
+
const top = sticky ? maxTop : Math.min(scrollTop, maxTop);
|
|
618
|
+
let start = 0;
|
|
619
|
+
while (start < msgs.length && cumulative[start + 1] <= top + 0.5)
|
|
620
|
+
start++;
|
|
621
|
+
let end = start;
|
|
622
|
+
// 严格钳制: 端界不越过 availH, 保证 transcript 内容行数 ≤ 分区高度 (互不覆盖)
|
|
623
|
+
while (end + 1 < msgs.length && cumulative[end + 1] <= top + availH)
|
|
624
|
+
end++;
|
|
625
|
+
const visible = useMemo(() => msgs.slice(start, end + 1), [msgs, start, end]);
|
|
626
|
+
const scrolledOut = maxTop > 0 && !sticky;
|
|
627
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: hasRails ? 'row' : 'column', children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(Messages, { msgs: visible }), scrolledOut && (_jsxs(Text, { color: THEME.muted, children: ["\u25BE \u4E0A\u6EDA ", top, " \u884C \u00B7 Ctrl+U/D \u7FFB\u9875 \u00B7 Home \u9876 \u00B7 End \u56DE\u5E95"] })), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) })), transient && (_jsx(Box, { children: _jsx(Text, { children: transient }) }))] }), hasRails && (_jsx(Box, { width: 36, marginLeft: 1, flexDirection: "column", justifyContent: "flex-start", children: railNames.map((n) => _jsxs(Text, { color: THEME.muted, children: [n, "\\n", widgets[n]] }, n)) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: W, loading: loadingFiles })), picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: W })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: THEME.accent, children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen && !picker, placeholder: COMPOSER_PLACEHOLDER }, tiKey)] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) })] }));
|
|
563
628
|
};
|
|
564
629
|
export { InkApp };
|
|
565
630
|
// ─── 启动 ────────────────────────────────────────────────────────────────────
|
|
@@ -582,15 +647,15 @@ export function stopInk() {
|
|
|
582
647
|
}
|
|
583
648
|
}
|
|
584
649
|
export function inkAppendLine(line) {
|
|
585
|
-
|
|
586
|
-
if (fn)
|
|
587
|
-
fn(line);
|
|
650
|
+
appendMsg(line);
|
|
588
651
|
}
|
|
589
652
|
/** 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 → 完成态). 无消息时追加. */
|
|
590
653
|
export function inkReplaceLastLine(line) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
654
|
+
replaceLastMsg(line);
|
|
655
|
+
}
|
|
656
|
+
/** 2026-09-08: 按内容标记原地替换一条消息 (占位框 → 启动面板完整内容). */
|
|
657
|
+
export function inkReplaceMatchingLine(marker, line) {
|
|
658
|
+
replaceMarkerMsg(marker, line);
|
|
594
659
|
}
|
|
595
660
|
export function inkSetStatus(s) {
|
|
596
661
|
const fn = globalThis.__inkSetStatus;
|
|
@@ -598,13 +663,9 @@ export function inkSetStatus(s) {
|
|
|
598
663
|
fn(s);
|
|
599
664
|
}
|
|
600
665
|
export function inkSetThinking(v) {
|
|
601
|
-
|
|
602
|
-
if (fn)
|
|
603
|
-
fn(v);
|
|
666
|
+
setUiThinking(v);
|
|
604
667
|
}
|
|
605
668
|
/** 2026-08-10: 设置/清除临时状态行 (自动整理/经验整理). 传 null 清空 → 显示为空 */
|
|
606
669
|
export function inkSetTransient(v) {
|
|
607
|
-
|
|
608
|
-
if (fn)
|
|
609
|
-
fn(v);
|
|
670
|
+
setUiTransient(v);
|
|
610
671
|
}
|