hiwork-core 0.0.37 → 0.0.39
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/ai-router-client.js +152 -4
- package/lib/browser-login.js +9 -2
- package/lib/client-policy.js +77 -2
- package/lib/client.js +34 -114
- package/lib/identity-context.js +50 -0
- package/lib/index.js +532 -79
- package/lib/knowledge-config.js +14 -12
- package/lib/memory-config.js +7 -7
- package/lib/orchestration.js +50 -3
- package/lib/provision-session.js +1 -0
- package/lib/provision.js +5 -0
- package/lib/session-fanout.js +29 -0
- package/lib/session-supervisor.js +156 -0
- package/lib/ttl-cache.js +45 -0
- package/lib/types/ai-router-client.d.ts +74 -0
- package/lib/types/client/menu-preferences.d.ts +4 -3
- package/lib/types/client/styles.d.ts +5 -4
- package/lib/types/client/types.d.ts +7 -1
- package/lib/types/client/user-badge.d.ts +7 -2
- package/lib/types/client-policy.d.ts +30 -2
- package/lib/types/identity-context.d.ts +54 -0
- package/lib/types/index.d.ts +15 -3
- package/lib/types/orchestration.d.ts +5 -3
- package/lib/types/provision-session.d.ts +9 -0
- package/lib/types/provision.d.ts +5 -0
- package/lib/types/session-fanout.d.ts +19 -0
- package/lib/types/session-supervisor.d.ts +101 -0
- package/lib/types/ttl-cache.d.ts +29 -0
- package/lib/types/types.d.ts +6 -0
- package/package.json +26 -25
package/lib/ai-router-client.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { gatewayUserAgent } from "./user-agent.js";
|
|
2
|
+
/** 模块级活动 sink:由宿主(index.ts)在 apply 时挂、在 effect 清理时摘。 */
|
|
3
|
+
let sessionActivitySink;
|
|
4
|
+
/** 挂载活动 sink(传 undefined 等价于清除,便于测试与宿主无处可摘时兜底)。 */
|
|
5
|
+
export function setSessionActivitySink(sink) {
|
|
6
|
+
sessionActivitySink = sink;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 清除活动 sink,**仅当当前 sink 就是入参那一个**。
|
|
10
|
+
* HMR 会重挂同一个模块:旧 fiber 的清理回调可能在新的 sink 挂上之后才跑,
|
|
11
|
+
* 无条件清空会把新挂载的观察者一起摘掉(心跳与 401 汇聚会静默失效)。
|
|
12
|
+
*/
|
|
13
|
+
export function clearSessionActivitySink(sink) {
|
|
14
|
+
if (sessionActivitySink === sink)
|
|
15
|
+
sessionActivitySink = undefined;
|
|
16
|
+
}
|
|
2
17
|
/**
|
|
3
18
|
* 读错误响应体里的结构化 `code`(读不到 → 空串)。
|
|
4
19
|
*
|
|
@@ -18,6 +33,8 @@ async function knowledgeErrorCodeFromBody(res) {
|
|
|
18
33
|
const AUTH_AUTHORIZE_PATH = '/api/auth/authorize';
|
|
19
34
|
const AUTH_TOKEN_PATH = '/api/auth/token';
|
|
20
35
|
const AUTH_SESSIONS_PATH = '/api/auth/sessions';
|
|
36
|
+
/** refresh grant 静默重认证端点(M2 网关起;老网关无此路由回 404)。 */
|
|
37
|
+
const AUTH_REFRESH_PATH = '/api/auth/refresh';
|
|
21
38
|
/** 自助用量与成本端点(均挂在网关 /api/me 会话组下,鉴权由 SessionAuth 负责)。 */
|
|
22
39
|
const ME_ANALYTICS_SUMMARY_PATH = '/api/me/analytics/summary';
|
|
23
40
|
const ME_ANALYTICS_TRENDS_PATH = '/api/me/analytics/trends';
|
|
@@ -254,8 +271,22 @@ export class AiRouterClient {
|
|
|
254
271
|
throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
|
|
255
272
|
if (sid === undefined)
|
|
256
273
|
throw new GatewayError('GATEWAY_UNREACHABLE', '网关登录成功但未返回会话 Cookie', res.status);
|
|
257
|
-
|
|
258
|
-
|
|
274
|
+
// 表单登录响应体带 expires_at(网关 C5 起)与 refresh_token(M2 起):前者优先于 Set-Cookie
|
|
275
|
+
// 的过期属性,两者都没有(旧网关)时留空,由调用方的「按假定寿命估算」路径接手。
|
|
276
|
+
// 两个字段都从 user 上摘掉(refresh_token 是密钥,绝不能混进 GatewayUser 被当作展示字段)。
|
|
277
|
+
const { expires_at: rawExpiresAt, refresh_token: rawRefreshToken, ...user } = await res.json();
|
|
278
|
+
const bodyExpiresAt = typeof rawExpiresAt === 'string' && !Number.isNaN(Date.parse(rawExpiresAt))
|
|
279
|
+
? rawExpiresAt
|
|
280
|
+
: undefined;
|
|
281
|
+
const sessionExpiresAt = bodyExpiresAt ?? parseSessionCookieExpiry(res.headers);
|
|
282
|
+
// grant 只收「字符串且非空」:空串/非字符串一律当老网关没给,调用方据此清陈旧 grant。
|
|
283
|
+
const refreshToken = typeof rawRefreshToken === 'string' && rawRefreshToken !== '' ? rawRefreshToken : undefined;
|
|
284
|
+
return {
|
|
285
|
+
sessionCookie: `session_id=${sid}`,
|
|
286
|
+
user,
|
|
287
|
+
...(sessionExpiresAt === undefined ? {} : { sessionExpiresAt }),
|
|
288
|
+
...(refreshToken === undefined ? {} : { refreshToken }),
|
|
289
|
+
};
|
|
259
290
|
}
|
|
260
291
|
/**
|
|
261
292
|
* POST /api/auth/token:用一次性 code + PKCE verifier 换取会话。
|
|
@@ -282,7 +313,62 @@ export class AiRouterClient {
|
|
|
282
313
|
if (data === undefined || data.session_id === undefined || data.session_id === '') {
|
|
283
314
|
throw new GatewayError('GATEWAY_UNREACHABLE', '网关兑换成功但未返回会话标识', res.status);
|
|
284
315
|
}
|
|
285
|
-
|
|
316
|
+
// 服务端已回 expires_at(spec §4.1a);只收「字符串且可解析」的值,原样保留服务端给的
|
|
317
|
+
// 时刻串,坏值当未知交给估算路径。
|
|
318
|
+
const rawExpiresAt = typeof data.expires_at === 'string' ? data.expires_at : undefined;
|
|
319
|
+
const sessionExpiresAt = rawExpiresAt !== undefined && !Number.isNaN(Date.parse(rawExpiresAt))
|
|
320
|
+
? rawExpiresAt
|
|
321
|
+
: undefined;
|
|
322
|
+
// grant 同样「字符串非空才收」(M2 起下发;老网关缺失即 undefined)。
|
|
323
|
+
const refreshToken = typeof data.refresh_token === 'string' && data.refresh_token !== '' ? data.refresh_token : undefined;
|
|
324
|
+
return {
|
|
325
|
+
sessionCookie: `session_id=${data.session_id}`,
|
|
326
|
+
user: data.user,
|
|
327
|
+
...(sessionExpiresAt === undefined ? {} : { sessionExpiresAt }),
|
|
328
|
+
...(refreshToken === undefined ? {} : { refreshToken }),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* POST /api/auth/refresh:用 refresh grant 换一份新会话(M2 网关的静默重认证)。
|
|
333
|
+
*
|
|
334
|
+
* 拒绝语义必须分开,调用方据此决定「弃用 grant」还是「保留待重试」:
|
|
335
|
+
* - 400 = grant 无效(过期 / 已被轮换 / 复用检测拒绝)→ `REFRESH_GRANT_INVALID`,本地作废;
|
|
336
|
+
* - 404 = 老网关根本没有这条路由 → `REFRESH_GRANT_ABSENT`,本地作废且**静默降级**;
|
|
337
|
+
* - 其余非 2xx(5xx / 反代错误页)与网络层异常 → `GATEWAY_UNREACHABLE`,grant 保留,
|
|
338
|
+
* 下次 SESSION_EXPIRED 再试。
|
|
339
|
+
*
|
|
340
|
+
* 任何日志/异常信息都不得包含 refresh_token / session_id 明文。
|
|
341
|
+
*/
|
|
342
|
+
async refreshSession(refreshToken) {
|
|
343
|
+
const res = await this.requestOrUnreachable(AUTH_REFRESH_PATH, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
body: { refresh_token: refreshToken },
|
|
346
|
+
});
|
|
347
|
+
if (res.status === 400)
|
|
348
|
+
throw new GatewayError('REFRESH_GRANT_INVALID', '登录已过期,请重新登录', 400);
|
|
349
|
+
if (res.status === 404)
|
|
350
|
+
throw new GatewayError('REFRESH_GRANT_ABSENT', '网关不支持静默续期', 404);
|
|
351
|
+
if (!res.ok)
|
|
352
|
+
throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
|
|
353
|
+
// 与 exchangeCode 同理用 readJson(不把响应片段带进异常信息)。
|
|
354
|
+
const data = await readJson(res);
|
|
355
|
+
// 响应体优先取 session_id(契约字段);网关只下发 Set-Cookie 时回落解析 Cookie,
|
|
356
|
+
// 两处都没有才算契约违约(与 login 的判据一致)。
|
|
357
|
+
const sid = typeof data?.session_id === 'string' && data.session_id !== '' ? data.session_id : extractSessionId(res.headers);
|
|
358
|
+
if (data === undefined || data.user === undefined || sid === undefined) {
|
|
359
|
+
throw new GatewayError('GATEWAY_UNREACHABLE', '网关续期成功但未返回会话标识', res.status);
|
|
360
|
+
}
|
|
361
|
+
const rawExpiresAt = typeof data.expires_at === 'string' ? data.expires_at : undefined;
|
|
362
|
+
const sessionExpiresAt = rawExpiresAt !== undefined && !Number.isNaN(Date.parse(rawExpiresAt))
|
|
363
|
+
? rawExpiresAt
|
|
364
|
+
: undefined;
|
|
365
|
+
const rotated = typeof data.refresh_token === 'string' && data.refresh_token !== '' ? data.refresh_token : undefined;
|
|
366
|
+
return {
|
|
367
|
+
sessionCookie: `session_id=${sid}`,
|
|
368
|
+
user: data.user,
|
|
369
|
+
...(sessionExpiresAt === undefined ? {} : { sessionExpiresAt }),
|
|
370
|
+
...(rotated === undefined ? {} : { refreshToken: rotated }),
|
|
371
|
+
};
|
|
286
372
|
}
|
|
287
373
|
/** GET /api/auth/sessions:当前账号的登录设备列表。 */
|
|
288
374
|
async listSessions(sessionCookie) {
|
|
@@ -551,6 +637,11 @@ export class AiRouterClient {
|
|
|
551
637
|
if (res.status === 503) {
|
|
552
638
|
throw new GatewayError('MCP_ASSERTION_DISABLED', '本部署未启用 MCP 断言签发,请联系管理员', 503);
|
|
553
639
|
}
|
|
640
|
+
// 429:服务端限流(每用户 60 次/分)。与其余拒绝不同,这不是终局——调用方(MCP 同步)
|
|
641
|
+
// 吞掉单条失败等下一轮即可,故给独立 code 让消费方能区分「等一会」与「找管理员」。
|
|
642
|
+
if (res.status === 429) {
|
|
643
|
+
throw new GatewayError('MCP_ASSERTION_RATE_LIMITED', '断言签发过于频繁,稍后自动重试', 429);
|
|
644
|
+
}
|
|
554
645
|
if (!res.ok)
|
|
555
646
|
throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
|
|
556
647
|
return await parseMeJson(res);
|
|
@@ -969,7 +1060,26 @@ export class AiRouterClient {
|
|
|
969
1060
|
}
|
|
970
1061
|
else if (opts.body !== undefined)
|
|
971
1062
|
init.body = JSON.stringify(opts.body);
|
|
972
|
-
|
|
1063
|
+
const res = await fetch(`${this.baseURL}${path}`, init);
|
|
1064
|
+
// 只有「带会话的请求」才产生活动信号:登录/兑换等尚无会话的请求不参与心跳与恢复。
|
|
1065
|
+
if (opts.sessionCookie !== undefined) {
|
|
1066
|
+
try {
|
|
1067
|
+
if (res.status === 401)
|
|
1068
|
+
sessionActivitySink?.({ kind: 'unauthorized' });
|
|
1069
|
+
else if (res.ok) {
|
|
1070
|
+
// 响应头带会话绝对寿命(网关 C5 起):透传给宿主更新内存估计;不可解析当缺失。
|
|
1071
|
+
const headerExpiresAt = res.headers.get('x-session-expires-at');
|
|
1072
|
+
const expiresAt = headerExpiresAt !== null && !Number.isNaN(Date.parse(headerExpiresAt))
|
|
1073
|
+
? headerExpiresAt
|
|
1074
|
+
: undefined;
|
|
1075
|
+
sessionActivitySink?.(expiresAt === undefined ? { kind: 'success' } : { kind: 'success', expiresAt });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
// sink 是外部注入的观察者:它的异常绝不能影响请求结果本身。
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return res;
|
|
973
1083
|
}
|
|
974
1084
|
}
|
|
975
1085
|
/**
|
|
@@ -1038,6 +1148,44 @@ export function extractSessionId(headers) {
|
|
|
1038
1148
|
}
|
|
1039
1149
|
return undefined;
|
|
1040
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* 从 Set-Cookie 解析会话过期时间(ISO 串;解析不出返回 undefined)。
|
|
1153
|
+
* 与 {@link extractSessionId} 同源遍历全部 Set-Cookie,但只认 `session_id` 那一节里的
|
|
1154
|
+
* `Expires=`(绝对时刻,`Date.parse`,NaN 弃用)或 `Max-Age=`(相对秒数,now + 秒);
|
|
1155
|
+
* 两者都有优先 `Expires`——服务端给的是权威时刻,Max-Age 只是保守回退。
|
|
1156
|
+
* 旧网关可能两个属性都不带(网关契约尚未下发过期信息),此时返回 undefined,
|
|
1157
|
+
* 由调用方的「按假定寿命估算」路径接手,绝不在这里抛错或猜一个假值。
|
|
1158
|
+
*
|
|
1159
|
+
* 旧运行时回退 `get('set-cookie')` 会拿到逗号合并串,故先按「逗号 + cookie 名=」切节:
|
|
1160
|
+
* `Expires` 的日期串里也有逗号,但逗号后面不是 `name=`,不会被误切。
|
|
1161
|
+
* `now` 可注入以便测试(默认 `Date.now()`)。
|
|
1162
|
+
*/
|
|
1163
|
+
export function parseSessionCookieExpiry(headers, now = Date.now()) {
|
|
1164
|
+
const raws = typeof headers.getSetCookie === 'function'
|
|
1165
|
+
? headers.getSetCookie()
|
|
1166
|
+
: [headers.get('set-cookie')].filter((raw) => raw !== null);
|
|
1167
|
+
for (const raw of raws) {
|
|
1168
|
+
for (const section of raw.split(/,\s*(?=[!#$%&'*+\-.^_`|~0-9A-Za-z]+=)/)) {
|
|
1169
|
+
if (!/(?:^|;\s*)session_id=/.test(section))
|
|
1170
|
+
continue;
|
|
1171
|
+
const expires = /;\s*Expires=([^;]+)/i.exec(section);
|
|
1172
|
+
const expiresRaw = expires?.[1];
|
|
1173
|
+
if (expiresRaw !== undefined) {
|
|
1174
|
+
const ms = Date.parse(expiresRaw);
|
|
1175
|
+
if (!Number.isNaN(ms))
|
|
1176
|
+
return new Date(ms).toISOString();
|
|
1177
|
+
}
|
|
1178
|
+
const maxAge = /;\s*Max-Age=(-?\d+)/i.exec(section);
|
|
1179
|
+
if (maxAge !== null) {
|
|
1180
|
+
const seconds = Number(maxAge[1]);
|
|
1181
|
+
if (Number.isFinite(seconds))
|
|
1182
|
+
return new Date(now + seconds * 1000).toISOString();
|
|
1183
|
+
}
|
|
1184
|
+
return undefined;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1041
1189
|
/** 读取 JSON 响应体:解析失败返回 undefined(不抛错、不记录原文,避免敏感字段进日志)。 */
|
|
1042
1190
|
async function readJson(res) {
|
|
1043
1191
|
try {
|
package/lib/browser-login.js
CHANGED
|
@@ -15,7 +15,7 @@ import { hostname } from 'node:os';
|
|
|
15
15
|
import { buildAuthorizeURL } from "./ai-router-client.js";
|
|
16
16
|
// 供给尾部与端口契约在 provision-session.ts(该模块不反向 import 本文件,无循环依赖)。
|
|
17
17
|
import { provisionSession, toLoginError, } from "./provision-session.js";
|
|
18
|
-
import { SESSION_REF } from "./provision.js";
|
|
18
|
+
import { REFRESH_TOKEN_REF, SESSION_REF } from "./provision.js";
|
|
19
19
|
import { PKG_VERSION } from "./version.js";
|
|
20
20
|
/** 待完成授权请求的有效期:超过 10 分钟的回调一律拒绝(授权页/一次性 code 早已失效)。 */
|
|
21
21
|
export const BROWSER_LOGIN_TTL_MS = 10 * 60 * 1000;
|
|
@@ -241,12 +241,18 @@ export async function completeBrowserLogin(input, opts) {
|
|
|
241
241
|
const baseURL = opts.value.baseURL;
|
|
242
242
|
if (baseURL === undefined || baseURL === '')
|
|
243
243
|
return await failBrowserLogin(opts.state, NO_BASE_URL_ERROR, log);
|
|
244
|
-
const { sessionCookie, user } = await opts.gateway.exchangeCode({
|
|
244
|
+
const { sessionCookie, user, sessionExpiresAt, refreshToken } = await opts.gateway.exchangeCode({
|
|
245
245
|
code: input.code,
|
|
246
246
|
codeVerifier: pending.codeVerifier,
|
|
247
247
|
deviceId: pending.deviceId,
|
|
248
248
|
});
|
|
249
249
|
await opts.credentials.set(SESSION_REF, sessionCookie);
|
|
250
|
+
// 与表单登录同批落盘(浏览器授权是主登录面,缺了这一步静默续期对它永远无效):
|
|
251
|
+
// 有 grant 即写,老网关没给则清掉残留旧值(防上一个账号的 grant 与新会话并存)。
|
|
252
|
+
if (refreshToken === undefined)
|
|
253
|
+
await opts.credentials.unset(REFRESH_TOKEN_REF).catch(() => { });
|
|
254
|
+
else
|
|
255
|
+
await opts.credentials.set(REFRESH_TOKEN_REF, refreshToken);
|
|
250
256
|
const username = typeof user.username === 'string' && user.username.trim() !== '' ? user.username.trim() : undefined;
|
|
251
257
|
await provisionSession({
|
|
252
258
|
gateway: opts.gateway,
|
|
@@ -258,6 +264,7 @@ export async function completeBrowserLogin(input, opts) {
|
|
|
258
264
|
sessionCookie,
|
|
259
265
|
user,
|
|
260
266
|
username,
|
|
267
|
+
sessionExpiresAt,
|
|
261
268
|
requestUnset: ['browserLoginRequest', 'browserLogin'],
|
|
262
269
|
...(opts.defaultModel === undefined ? {} : { defaultModel: opts.defaultModel }),
|
|
263
270
|
...(opts.ensureWorkspace === undefined ? {} : { ensureWorkspace: opts.ensureWorkspace }),
|
package/lib/client-policy.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* 客户端版本策略(spec §6):`GET /api/client/policy` 的免登录只读拉取。
|
|
3
3
|
*
|
|
4
4
|
* 网关返回的是**最终判定**(`loginMode` 等),插件只做投影不做策略逻辑,因此管理端
|
|
5
|
-
* 调整 `client.login_mode`
|
|
5
|
+
* 调整 `client.login_mode` 后无需重启插件、无需发版:由 {@link createClientPolicyPoller}
|
|
6
|
+
* 每 6 小时重拉一次(对齐服务端 `Cache-Control: max-age=300` 的注释预期),失败按
|
|
7
|
+
* 1min → 10min → 1h 阶梯退避重试,紧急止血策略(如 `login_mode=form`)由此能到达在线客户端。
|
|
6
8
|
*
|
|
7
|
-
* 本模块只负责「拉 +
|
|
9
|
+
* 本模块只负责「拉 + 严格解析 + 轮询节律」,投影与 fail-open 缺省由 `src/index.ts` 的
|
|
8
10
|
* `projectLoginMode` 完成:**非 200 / 非 JSON / 枚举非法 / 超时 / 网络异常一律返回
|
|
9
11
|
* undefined**,调用方据此回退 `DEFAULT_LOGIN_MODE`,绝不把用户锁在门外(spec §5.4 / §6.4)。
|
|
10
12
|
*/
|
|
@@ -92,3 +94,76 @@ export async function fetchClientPolicy(opts) {
|
|
|
92
94
|
return undefined;
|
|
93
95
|
}
|
|
94
96
|
}
|
|
97
|
+
/** 策略轮询间隔(6h,对齐服务端 `Cache-Control: max-age=300` 的注释预期)。 */
|
|
98
|
+
export const CLIENT_POLICY_POLL_INTERVAL_MS = 6 * 60 * 60_000;
|
|
99
|
+
/**
|
|
100
|
+
* 失败退避阶梯(1min → 10min → 1h),末档封顶循环。
|
|
101
|
+
* 退避只影响「多久后重试」,不影响已经投影好的策略值——失败时保留上一轮结果。
|
|
102
|
+
*/
|
|
103
|
+
export const CLIENT_POLICY_RETRY_DELAYS_MS = [60_000, 10 * 60_000, 60 * 60_000];
|
|
104
|
+
/**
|
|
105
|
+
* 自排程的策略轮询器(不用 setInterval:每轮的间隔由上一轮结果决定)。
|
|
106
|
+
* - `attempt` 返回 true(成功拉到并投影)→ 6h 后再来;
|
|
107
|
+
* - 返回 false 或抛异常 → 按 {@link CLIENT_POLICY_RETRY_DELAYS_MS} 退避,末档封顶循环;
|
|
108
|
+
* - 任意一次成功把退避档归零(下一次失败重新从 1min 起步);
|
|
109
|
+
* - 定时器 `unref`,不拖住进程退出;`trigger()` 幂等(已排程/在飞时是 no-op),
|
|
110
|
+
* 让宿主 200ms 消费循环可以无脑反复调用。
|
|
111
|
+
*/
|
|
112
|
+
export function createClientPolicyPoller(deps) {
|
|
113
|
+
let timer;
|
|
114
|
+
let disposed = false;
|
|
115
|
+
let running = false;
|
|
116
|
+
let retryIndex = 0;
|
|
117
|
+
const schedule = (delayMs) => {
|
|
118
|
+
if (disposed)
|
|
119
|
+
return;
|
|
120
|
+
if (timer !== undefined)
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
timer = setTimeout(() => {
|
|
123
|
+
timer = undefined;
|
|
124
|
+
void run();
|
|
125
|
+
}, delayMs);
|
|
126
|
+
timer.unref?.();
|
|
127
|
+
};
|
|
128
|
+
const run = async () => {
|
|
129
|
+
if (disposed || running)
|
|
130
|
+
return;
|
|
131
|
+
running = true;
|
|
132
|
+
let ok = false;
|
|
133
|
+
try {
|
|
134
|
+
ok = await deps.attempt();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// attempt 的实现方约定「不抛」,但轮询器不能因此死掉:异常一律视同失败。
|
|
138
|
+
ok = false;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
running = false;
|
|
142
|
+
}
|
|
143
|
+
if (disposed)
|
|
144
|
+
return;
|
|
145
|
+
if (ok) {
|
|
146
|
+
retryIndex = 0;
|
|
147
|
+
schedule(CLIENT_POLICY_POLL_INTERVAL_MS);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const delay = CLIENT_POLICY_RETRY_DELAYS_MS[Math.min(retryIndex, CLIENT_POLICY_RETRY_DELAYS_MS.length - 1)];
|
|
151
|
+
retryIndex += 1;
|
|
152
|
+
deps.log?.(`hiwork-core: 客户端策略拉取失败,${Math.round(delay / 1000)}s 后重试`);
|
|
153
|
+
schedule(delay);
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
trigger: () => {
|
|
157
|
+
if (disposed || running || timer !== undefined)
|
|
158
|
+
return;
|
|
159
|
+
void run();
|
|
160
|
+
},
|
|
161
|
+
dispose: () => {
|
|
162
|
+
disposed = true;
|
|
163
|
+
if (timer !== undefined) {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
timer = undefined;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -1065,108 +1065,6 @@ function pickSettingsSectionButton(buttons, labels) {
|
|
|
1065
1065
|
}
|
|
1066
1066
|
return void 0;
|
|
1067
1067
|
}
|
|
1068
|
-
var cancelPendingNavigation;
|
|
1069
|
-
function isVisibleSubtree(el) {
|
|
1070
|
-
if (el === null) return false;
|
|
1071
|
-
const style = getComputedStyle(el);
|
|
1072
|
-
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
1073
|
-
return el.parentElement === null || isVisibleSubtree(el.parentElement);
|
|
1074
|
-
}
|
|
1075
|
-
function openSettingsSection(root, label, onMissing) {
|
|
1076
|
-
const labels = typeof label === "string" ? [label] : label;
|
|
1077
|
-
if (cancelPendingNavigation !== void 0 && document.querySelector('[role="dialog"]') !== null) {
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
const trigger = root?.querySelector('[aria-haspopup="dialog"]');
|
|
1081
|
-
if (trigger === null || trigger === void 0) {
|
|
1082
|
-
onMissing?.();
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
cancelPendingNavigation?.();
|
|
1086
|
-
let frame;
|
|
1087
|
-
let finished = false;
|
|
1088
|
-
let sawPanel = false;
|
|
1089
|
-
let reopenTimer;
|
|
1090
|
-
let watchdog;
|
|
1091
|
-
let lastSignature = "";
|
|
1092
|
-
const IDLE_MS = 1500;
|
|
1093
|
-
const finishFailure = () => {
|
|
1094
|
-
cleanup();
|
|
1095
|
-
console.warn(`[hiwork-core] \u672A\u627E\u5230\u8BBE\u7F6E\u5206\u533A\uFF1A${labels.join(" / ")}`);
|
|
1096
|
-
onMissing?.();
|
|
1097
|
-
};
|
|
1098
|
-
const armWatchdog = () => {
|
|
1099
|
-
window.clearTimeout(watchdog);
|
|
1100
|
-
watchdog = window.setTimeout(() => {
|
|
1101
|
-
if (finished) return;
|
|
1102
|
-
if (select()) return;
|
|
1103
|
-
finishFailure();
|
|
1104
|
-
}, IDLE_MS);
|
|
1105
|
-
};
|
|
1106
|
-
const observer = new MutationObserver(() => {
|
|
1107
|
-
schedule();
|
|
1108
|
-
});
|
|
1109
|
-
const cleanup = () => {
|
|
1110
|
-
if (finished) return;
|
|
1111
|
-
finished = true;
|
|
1112
|
-
observer.disconnect();
|
|
1113
|
-
window.clearTimeout(watchdog);
|
|
1114
|
-
window.clearTimeout(reopenTimer);
|
|
1115
|
-
if (frame !== void 0) window.cancelAnimationFrame(frame);
|
|
1116
|
-
if (cancelPendingNavigation === cleanup) cancelPendingNavigation = void 0;
|
|
1117
|
-
};
|
|
1118
|
-
const select = () => {
|
|
1119
|
-
if (finished) return false;
|
|
1120
|
-
const panels = Array.from(document.querySelectorAll('[role="dialog"]')).filter((panel) => isVisibleSubtree(panel));
|
|
1121
|
-
const buttons = panels.flatMap((panel) => Array.from(panel.querySelectorAll("nav button")));
|
|
1122
|
-
const target = panels.length === 0 ? void 0 : pickSettingsSectionButton(buttons, labels);
|
|
1123
|
-
const targetActive = target !== void 0 && (target.className.includes("active") || target.getAttribute("aria-current") === "true");
|
|
1124
|
-
const signature = `${panels.length}:${targetActive ? 1 : 0}`;
|
|
1125
|
-
if (signature !== lastSignature) {
|
|
1126
|
-
lastSignature = signature;
|
|
1127
|
-
armWatchdog();
|
|
1128
|
-
}
|
|
1129
|
-
if (panels.length === 0) {
|
|
1130
|
-
if (sawPanel) {
|
|
1131
|
-
sawPanel = false;
|
|
1132
|
-
reopenTimer = window.setTimeout(() => {
|
|
1133
|
-
reopenTimer = void 0;
|
|
1134
|
-
if (finished || sawPanel) return;
|
|
1135
|
-
if (typeof document === "undefined" || document.querySelector('[role="dialog"]') !== null) return;
|
|
1136
|
-
trigger.click();
|
|
1137
|
-
}, 300);
|
|
1138
|
-
return false;
|
|
1139
|
-
}
|
|
1140
|
-
trigger.click();
|
|
1141
|
-
return false;
|
|
1142
|
-
}
|
|
1143
|
-
sawPanel = true;
|
|
1144
|
-
if (target === void 0) return false;
|
|
1145
|
-
if (targetActive) {
|
|
1146
|
-
cleanup();
|
|
1147
|
-
return true;
|
|
1148
|
-
}
|
|
1149
|
-
target.click();
|
|
1150
|
-
return false;
|
|
1151
|
-
};
|
|
1152
|
-
const schedule = () => {
|
|
1153
|
-
if (finished || frame !== void 0) return;
|
|
1154
|
-
frame = window.requestAnimationFrame(() => {
|
|
1155
|
-
frame = void 0;
|
|
1156
|
-
select();
|
|
1157
|
-
});
|
|
1158
|
-
};
|
|
1159
|
-
armWatchdog();
|
|
1160
|
-
observer.observe(document.body, {
|
|
1161
|
-
childList: true,
|
|
1162
|
-
subtree: true,
|
|
1163
|
-
// 壳把激活态应用到导航钮是纯属性变更(class/aria-current),必须监听才能复核收敛
|
|
1164
|
-
attributes: true,
|
|
1165
|
-
attributeFilter: ["class", "aria-current", "aria-expanded"]
|
|
1166
|
-
});
|
|
1167
|
-
cancelPendingNavigation = cleanup;
|
|
1168
|
-
schedule();
|
|
1169
|
-
}
|
|
1170
1068
|
|
|
1171
1069
|
// src/client/user-badge.tsx
|
|
1172
1070
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
@@ -1388,11 +1286,27 @@ function LogoutIcon() {
|
|
|
1388
1286
|
}
|
|
1389
1287
|
);
|
|
1390
1288
|
}
|
|
1391
|
-
function openGatewaySettings() {
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1289
|
+
function openGatewaySettings(openSettings) {
|
|
1290
|
+
openSettings();
|
|
1291
|
+
selectSettingsSection([AI_ROUTER_SECTION_LABEL]);
|
|
1292
|
+
}
|
|
1293
|
+
function selectSettingsSection(labels) {
|
|
1294
|
+
const started = Date.now();
|
|
1295
|
+
const tick = () => {
|
|
1296
|
+
const dialog = document.querySelector('[role="dialog"]');
|
|
1297
|
+
if (dialog !== null) {
|
|
1298
|
+
const button = pickSettingsSectionButton(
|
|
1299
|
+
Array.from(dialog.querySelectorAll("button")),
|
|
1300
|
+
labels
|
|
1301
|
+
);
|
|
1302
|
+
if (button !== void 0) {
|
|
1303
|
+
button.click();
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (Date.now() - started < 3e3) window.setTimeout(tick, 120);
|
|
1308
|
+
};
|
|
1309
|
+
tick();
|
|
1396
1310
|
}
|
|
1397
1311
|
function LogoutConfirm(props) {
|
|
1398
1312
|
const { onCancel } = props;
|
|
@@ -1500,7 +1414,9 @@ function UserBadge(props) {
|
|
|
1500
1414
|
style: railRowStyle,
|
|
1501
1415
|
title: displayName,
|
|
1502
1416
|
"aria-label": "\u6253\u5F00\u8BBE\u7F6E",
|
|
1503
|
-
onClick:
|
|
1417
|
+
onClick: () => {
|
|
1418
|
+
openGatewaySettings(props.openSettings);
|
|
1419
|
+
},
|
|
1504
1420
|
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: avatarStyle(24), children: initial })
|
|
1505
1421
|
}
|
|
1506
1422
|
),
|
|
@@ -1517,7 +1433,9 @@ function UserBadge(props) {
|
|
|
1517
1433
|
style: profileButtonStyle,
|
|
1518
1434
|
title: "\u6253\u5F00\u8BBE\u7F6E",
|
|
1519
1435
|
"aria-label": `\u6253\u5F00\u8BBE\u7F6E\uFF08${displayName}\uFF09`,
|
|
1520
|
-
onClick:
|
|
1436
|
+
onClick: () => {
|
|
1437
|
+
openGatewaySettings(props.openSettings);
|
|
1438
|
+
},
|
|
1521
1439
|
children: [
|
|
1522
1440
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: avatarStyle(24), children: initial }),
|
|
1523
1441
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: nameTextStyle, children: displayName })
|
|
@@ -1532,7 +1450,9 @@ function UserBadge(props) {
|
|
|
1532
1450
|
style: iconButtonStyle,
|
|
1533
1451
|
title: "\u8BBE\u7F6E",
|
|
1534
1452
|
"aria-label": "\u8BBE\u7F6E",
|
|
1535
|
-
onClick:
|
|
1453
|
+
onClick: () => {
|
|
1454
|
+
openGatewaySettings(props.openSettings);
|
|
1455
|
+
},
|
|
1536
1456
|
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_dsh_client_ui_primitives.IconSettingsOutlineMedium, { size: 16 })
|
|
1537
1457
|
}
|
|
1538
1458
|
),
|
|
@@ -3301,7 +3221,7 @@ function createFeatureRegistry() {
|
|
|
3301
3221
|
|
|
3302
3222
|
// src/client/menu-preferences.ts
|
|
3303
3223
|
var HIWORK_MENU_NS = "hiwork-menu";
|
|
3304
|
-
var DEFAULT_SIDEBAR_LIMIT =
|
|
3224
|
+
var DEFAULT_SIDEBAR_LIMIT = 6;
|
|
3305
3225
|
var MORE_MENU_ITEM_ID = "__hiwork_more__";
|
|
3306
3226
|
var MORE_MENU_LABEL = "\u66F4\u591A";
|
|
3307
3227
|
var MENU_SECTION_LABEL = "\u4FA7\u680F\u83DC\u5355";
|
|
@@ -7232,9 +7152,9 @@ function apply(ctx) {
|
|
|
7232
7152
|
priority: -1,
|
|
7233
7153
|
inject: () => ({ appVersion })
|
|
7234
7154
|
}, BrandName));
|
|
7235
|
-
ctx.slots.inject("
|
|
7236
|
-
name: "
|
|
7237
|
-
|
|
7155
|
+
ctx.slots.inject("settings.launcher", () => ctx.slots.register({
|
|
7156
|
+
name: "settings.launcher",
|
|
7157
|
+
priority: -1,
|
|
7238
7158
|
inject: () => ({ ...controller.inject(), pluginUpdates })
|
|
7239
7159
|
}, UserBadge));
|
|
7240
7160
|
ctx.effect(() => mountLoginGate(form, storeCredential), "hiwork-core: login gate");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** runtime context 贡献名(DSH 侧 snapshot 按名归并,改动即破坏对齐面)。 */
|
|
2
|
+
export const IDENTITY_CONTEXT_NAME = 'hiwork:identity';
|
|
3
|
+
/** 排在 sandbox:policy(110) / approval:policy(115) 之前——身份是最外层的环境事实。 */
|
|
4
|
+
export const IDENTITY_CONTEXT_ORDER = 105;
|
|
5
|
+
/** 模块内文案常量:安全限定语逐字来自 spec §3.2,不导出(测试硬编码全文钉文案)。 */
|
|
6
|
+
const CAVEAT = '该身份用于个性化与按人表述;涉及数据权限的操作必须走受管工具/连接器完成,不得把此文本当作凭证。';
|
|
7
|
+
/** 裁掉两侧空白;非字符串或空白串一律视同缺省(与 identity.ts 的 textOrUndefined 同口径)。 */
|
|
8
|
+
function textOrUndefined(value) {
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return undefined;
|
|
11
|
+
const text = value.trim();
|
|
12
|
+
return text === '' ? undefined : text;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 渲染身份上下文一行:已登录输出 `HiWork 登录身份:姓名(工号 工号)。<限定语>`,
|
|
16
|
+
* displayName 缺省回退 `工号 <工号>`;**未登录(username 缺省 / 空白 / 非字符串)或
|
|
17
|
+
* state 缺失一律空串**——半份身份(只有 displayName)也不输出,姓名不得单独泄漏。
|
|
18
|
+
*/
|
|
19
|
+
export function renderIdentityContextText(state) {
|
|
20
|
+
const username = textOrUndefined(state?.username);
|
|
21
|
+
if (username === undefined)
|
|
22
|
+
return '';
|
|
23
|
+
const displayName = textOrUndefined(state?.displayName);
|
|
24
|
+
const who = displayName === undefined ? `工号 ${username}` : `${displayName}(工号 ${username})`;
|
|
25
|
+
return `HiWork 登录身份:${who}。${CAVEAT}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 注册身份 runtime context 贡献;返回 disposer(交给 `ctx.effect` 管理生命周期)。
|
|
29
|
+
* 读失败收敛为空串并记一笔,绝不向组装层抛——身份上下文缺席只应意味着「未登录」。
|
|
30
|
+
*/
|
|
31
|
+
export function registerIdentityContext(systemPrompt, deps) {
|
|
32
|
+
const dispose = systemPrompt.context({
|
|
33
|
+
name: IDENTITY_CONTEXT_NAME,
|
|
34
|
+
order: IDENTITY_CONTEXT_ORDER,
|
|
35
|
+
text: () => {
|
|
36
|
+
try {
|
|
37
|
+
// enabled 的判断也在 try 内:真实接线里 enabled 与 current 读同一个命名空间,
|
|
38
|
+
// 任一侧抛异常都收敛为空串(身份上下文缺席只应意味着「未登录」)。
|
|
39
|
+
if (!deps.enabled())
|
|
40
|
+
return '';
|
|
41
|
+
return renderIdentityContextText(deps.current());
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
deps.log?.(`hiwork-core: 登录身份上下文读取失败(${error instanceof Error ? error.name : typeof error})`);
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return typeof dispose === 'function' ? dispose : () => { };
|
|
50
|
+
}
|