hiwork-core 0.0.19 → 0.0.21
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.md +8 -0
- package/lib/identity.js +111 -0
- package/lib/index.js +244 -6
- package/lib/memory-config.js +266 -0
- package/lib/types/identity.d.ts +64 -0
- package/lib/types/memory-config.d.ts +113 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,6 +136,14 @@ ctx.inject(['hiworkFeatureCenter'], (scope) => {
|
|
|
136
136
|
- [src/office-relay.ts](src/office-relay.ts):文档中继的 host 侧服务(`hiworkOfficeRelay`)——
|
|
137
137
|
跨 NAT 的在线编辑:插件把文档暂存到 DS 能访问的服务器侧(上传/轮询/取回/关闭),
|
|
138
138
|
用户侧不再需要入向可达性;同样只在 Host 持 cookie、失败用稳定信封。
|
|
139
|
+
- [src/identity.ts](src/identity.ts):当前登录身份的只读 host 服务(`hiworkIdentity`)——
|
|
140
|
+
业务插件(hiwork-memory 的记忆隔离键等)经 `ctx.get('hiworkIdentity')` 取
|
|
141
|
+
`{ loggedIn, subject, username, displayName, employeeName, teamName }`;`subject` 是
|
|
142
|
+
账号名的 sha256 前 16 位(`u_` 前缀,**不含账号原文**),未登录回 `{ loggedIn: false }`。
|
|
143
|
+
- [src/memory-config.ts](src/memory-config.ts):记忆系统配置下发的只读 host 服务(`hiworkMemory`)——
|
|
144
|
+
hiwork-memory 经 `ctx.get('hiworkMemory').config()` 取网关下发的 `panelUrl` / `serviceId` /
|
|
145
|
+
`teamIds` / `apiKey`(网关 `GET /api/me/memory-config`;snake_case → camelCase 的转换只在 Host 做),
|
|
146
|
+
成功结果 5 分钟内存缓存、`force` 可绕过;凭据只在内存里活、不落盘、不进日志。
|
|
139
147
|
- [src/default-model.ts](src/default-model.ts):供给成功后校正 `agent-default-model`(默认模型落点)。
|
|
140
148
|
- [src/client/login-gate.tsx](src/client/login-gate.tsx):全屏登录门禁页。
|
|
141
149
|
- [src/client/section.tsx](src/client/section.tsx):设置页「AI Router 网关」卡片。
|
package/lib/identity.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 当前登录身份的只读 host 服务(`hiworkIdentity`)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:hiwork-core 已经把「谁登录了」完整地记在 `ai-router` 设置命名空间里
|
|
5
|
+
* (`username` / `displayName` / `keys`),但那是**本插件的私有状态段**——业务插件
|
|
6
|
+
* 要拿「当前登录员工」做数据隔离(hiwork-memory 的记忆隔离键、按人区分的本地
|
|
7
|
+
* 目录名等),既不该去解析别人的设置段,也不该把登录态复制一份自己维护。
|
|
8
|
+
* 本模块把那层读取收敛成一个稳定的小服务:消费方 `ctx.get('hiworkIdentity')`
|
|
9
|
+
* 拿到的是一份**只读视图**,登录态怎么变、什么时候变都由 Host 说了算。
|
|
10
|
+
*
|
|
11
|
+
* 设计要点(与宿主既有 RPC 服务同构,参照 `src/office-credentials.ts`):
|
|
12
|
+
* - **只读、同步、不抛异常**:`current()` 同步返回快照,未登录返回
|
|
13
|
+
* `{ loggedIn: false }`,已登录但还没拉到 keys 时 `employeeName` / `teamName`
|
|
14
|
+
* 缺省而不是报错(首次登录的中间态、老网关不带这两个字段都会走到这里);
|
|
15
|
+
* - **绝不外泄账号原文进隔离键**:`subject = 'u_' + sha256(username) 的前 16 位 hex`。
|
|
16
|
+
* subject 会进存储键 / 目录名,哈希保证它同一账号恒等(跨会话、跨设备稳定)、
|
|
17
|
+
* 不同账号几乎必然不同,同时不把企业账号明文写进磁盘或日志;
|
|
18
|
+
* `username` 字段仍在视图里(界面要显示账号),但**不承担隔离键职责**;
|
|
19
|
+
* - **不缓存、不订阅**:每次 `current()` 都读一次当前命名空间值,消费方不再需要
|
|
20
|
+
* 关心 Host 何时刷新过登录态;
|
|
21
|
+
* - 服务经 `ctx.provide` 提供给同进程的其他插件,消费方用 `ctx.get` 探测
|
|
22
|
+
* (老 core 上它是 undefined → 插件自行回退,不假设本服务存在)。
|
|
23
|
+
*
|
|
24
|
+
* 本模块不 import cordis 运行时:provide 走结构化的 {@link IdentityContext} 端口,
|
|
25
|
+
* 调用方(index.ts)传真实 ctx;单测直接伪造。
|
|
26
|
+
*/
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
/** cordis 服务名(hiwork-memory 等消费方按此名 `ctx.get` 探测)。 */
|
|
29
|
+
export const HIWORK_IDENTITY_SERVICE = 'hiworkIdentity';
|
|
30
|
+
/** 视图字段名的语义名(供消费方日志 / 文档引用,避免各处硬编码字符串)。 */
|
|
31
|
+
export const IDENTITY_SUBJECT_PREFIX = 'u_';
|
|
32
|
+
/** 裁掉两侧空白;非字符串或空白串一律视同缺省。 */
|
|
33
|
+
function textOrUndefined(value) {
|
|
34
|
+
if (typeof value !== 'string')
|
|
35
|
+
return undefined;
|
|
36
|
+
const text = value.trim();
|
|
37
|
+
return text === '' ? undefined : text;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 由登录账号派生稳定 subject:`u_` + `sha256(账号名).hex` 的前 16 位。
|
|
41
|
+
*
|
|
42
|
+
* 输入先裁掉两侧空白(与视图里的 `username` 同口径),因此 `' alice '` 与 `'alice'`
|
|
43
|
+
* 派生同一个 subject;大小写不折叠——账号名在网关侧就是区分大小写的。
|
|
44
|
+
* **本函数绝不返回账号名原文**:subject 会落进存储键 / 目录名,哈希是隔离键
|
|
45
|
+
* 既能稳定标识同一员工、又不外泄账号的那条界线。
|
|
46
|
+
*/
|
|
47
|
+
export function deriveSubject(username) {
|
|
48
|
+
const digest = createHash('sha256').update(username.trim(), 'utf8').digest('hex');
|
|
49
|
+
return `${IDENTITY_SUBJECT_PREFIX}${digest.slice(0, 16)}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 从 keys 里挑员工名 / 团队名:逐条取第一个非空值,两项**互不掩盖**
|
|
53
|
+
* (某把 Key 只带了 team_name,不会让另一把 Key 的 employee_name 被丢掉)。
|
|
54
|
+
* 同一账号的各把 Key 属于同一个人,命名相同的部分取值一致,因此不需要按 keyId 挑。
|
|
55
|
+
*/
|
|
56
|
+
function pickKeyNames(keys) {
|
|
57
|
+
let employeeName;
|
|
58
|
+
let teamName;
|
|
59
|
+
for (const key of keys ?? []) {
|
|
60
|
+
if (employeeName === undefined)
|
|
61
|
+
employeeName = textOrUndefined(key?.employee_name);
|
|
62
|
+
if (teamName === undefined)
|
|
63
|
+
teamName = textOrUndefined(key?.team_name);
|
|
64
|
+
if (employeeName !== undefined && teamName !== undefined)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...(employeeName === undefined ? {} : { employeeName }),
|
|
69
|
+
...(teamName === undefined ? {} : { teamName }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** 视图投影(无状态;未登录 / 读失败一律收敛为 `{ loggedIn: false }`)。 */
|
|
73
|
+
class IdentityProvider {
|
|
74
|
+
deps;
|
|
75
|
+
constructor(deps) {
|
|
76
|
+
this.deps = deps;
|
|
77
|
+
}
|
|
78
|
+
current() {
|
|
79
|
+
try {
|
|
80
|
+
const state = this.deps.current();
|
|
81
|
+
const username = textOrUndefined(state?.username);
|
|
82
|
+
// 未登录:只给 loggedIn,绝不带半份身份出去。
|
|
83
|
+
if (username === undefined)
|
|
84
|
+
return { loggedIn: false };
|
|
85
|
+
const displayName = textOrUndefined(state?.displayName);
|
|
86
|
+
const { employeeName, teamName } = pickKeyNames(state?.keys);
|
|
87
|
+
return {
|
|
88
|
+
loggedIn: true,
|
|
89
|
+
subject: deriveSubject(username),
|
|
90
|
+
username,
|
|
91
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
92
|
+
...(employeeName === undefined ? {} : { employeeName }),
|
|
93
|
+
...(teamName === undefined ? {} : { teamName }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// 只读服务不该把宿主的读失败变成消费方的异常:收敛成「未登录」并记一笔。
|
|
98
|
+
this.deps.log?.(`hiwork-core: 登录身份读取失败(${error instanceof Error ? error.name : typeof error})`);
|
|
99
|
+
return { loggedIn: false };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 提供 host 侧服务 `hiworkIdentity`;返回 disposer(交给 `ctx.effect` 管理生命周期,
|
|
105
|
+
* 插件卸载时服务随 fiber 一并注销)。
|
|
106
|
+
*/
|
|
107
|
+
export function provideIdentity(ctx, deps) {
|
|
108
|
+
const service = new IdentityProvider(deps);
|
|
109
|
+
const dispose = ctx.provide(HIWORK_IDENTITY_SERVICE, service);
|
|
110
|
+
return typeof dispose === 'function' ? dispose : () => { };
|
|
111
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -3137,7 +3137,7 @@ function isUnloading(ctx) {
|
|
|
3137
3137
|
}
|
|
3138
3138
|
|
|
3139
3139
|
// src/version.ts
|
|
3140
|
-
var PKG_VERSION = false ? "dev" : "0.0.
|
|
3140
|
+
var PKG_VERSION = false ? "dev" : "0.0.21";
|
|
3141
3141
|
|
|
3142
3142
|
// src/ai-router-client.ts
|
|
3143
3143
|
var AUTH_AUTHORIZE_PATH = "/api/auth/authorize";
|
|
@@ -6385,6 +6385,228 @@ function registerPluginUpdatesRpc(ctx, deps) {
|
|
|
6385
6385
|
};
|
|
6386
6386
|
}
|
|
6387
6387
|
|
|
6388
|
+
// src/identity.ts
|
|
6389
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
6390
|
+
var HIWORK_IDENTITY_SERVICE = "hiworkIdentity";
|
|
6391
|
+
var IDENTITY_SUBJECT_PREFIX = "u_";
|
|
6392
|
+
function textOrUndefined5(value) {
|
|
6393
|
+
if (typeof value !== "string") return void 0;
|
|
6394
|
+
const text = value.trim();
|
|
6395
|
+
return text === "" ? void 0 : text;
|
|
6396
|
+
}
|
|
6397
|
+
function deriveSubject(username) {
|
|
6398
|
+
const digest = createHash2("sha256").update(username.trim(), "utf8").digest("hex");
|
|
6399
|
+
return `${IDENTITY_SUBJECT_PREFIX}${digest.slice(0, 16)}`;
|
|
6400
|
+
}
|
|
6401
|
+
function pickKeyNames(keys) {
|
|
6402
|
+
let employeeName;
|
|
6403
|
+
let teamName;
|
|
6404
|
+
for (const key of keys ?? []) {
|
|
6405
|
+
if (employeeName === void 0) employeeName = textOrUndefined5(key?.employee_name);
|
|
6406
|
+
if (teamName === void 0) teamName = textOrUndefined5(key?.team_name);
|
|
6407
|
+
if (employeeName !== void 0 && teamName !== void 0) break;
|
|
6408
|
+
}
|
|
6409
|
+
return {
|
|
6410
|
+
...employeeName === void 0 ? {} : { employeeName },
|
|
6411
|
+
...teamName === void 0 ? {} : { teamName }
|
|
6412
|
+
};
|
|
6413
|
+
}
|
|
6414
|
+
var IdentityProvider = class {
|
|
6415
|
+
constructor(deps) {
|
|
6416
|
+
this.deps = deps;
|
|
6417
|
+
}
|
|
6418
|
+
current() {
|
|
6419
|
+
try {
|
|
6420
|
+
const state = this.deps.current();
|
|
6421
|
+
const username = textOrUndefined5(state?.username);
|
|
6422
|
+
if (username === void 0) return { loggedIn: false };
|
|
6423
|
+
const displayName = textOrUndefined5(state?.displayName);
|
|
6424
|
+
const { employeeName, teamName } = pickKeyNames(state?.keys);
|
|
6425
|
+
return {
|
|
6426
|
+
loggedIn: true,
|
|
6427
|
+
subject: deriveSubject(username),
|
|
6428
|
+
username,
|
|
6429
|
+
...displayName === void 0 ? {} : { displayName },
|
|
6430
|
+
...employeeName === void 0 ? {} : { employeeName },
|
|
6431
|
+
...teamName === void 0 ? {} : { teamName }
|
|
6432
|
+
};
|
|
6433
|
+
} catch (error) {
|
|
6434
|
+
this.deps.log?.(
|
|
6435
|
+
`hiwork-core: \u767B\u5F55\u8EAB\u4EFD\u8BFB\u53D6\u5931\u8D25\uFF08${error instanceof Error ? error.name : typeof error}\uFF09`
|
|
6436
|
+
);
|
|
6437
|
+
return { loggedIn: false };
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
};
|
|
6441
|
+
function provideIdentity(ctx, deps) {
|
|
6442
|
+
const service = new IdentityProvider(deps);
|
|
6443
|
+
const dispose = ctx.provide(HIWORK_IDENTITY_SERVICE, service);
|
|
6444
|
+
return typeof dispose === "function" ? dispose : () => {
|
|
6445
|
+
};
|
|
6446
|
+
}
|
|
6447
|
+
|
|
6448
|
+
// src/memory-config.ts
|
|
6449
|
+
var HIWORK_MEMORY_SERVICE = "hiworkMemory";
|
|
6450
|
+
var ME_MEMORY_CONFIG_PATH = "/api/me/memory-config";
|
|
6451
|
+
var MEMORY_CONFIG_TTL_MS = 5 * 60 * 1e3;
|
|
6452
|
+
var MEMORY_CONFIG_TIMEOUT_MS = 1e4;
|
|
6453
|
+
var MESSAGE_NOT_CONFIGURED = "\u7BA1\u7406\u5458\u8FD8\u6CA1\u5F00\u542F\u56E2\u961F\u8BB0\u5FC6\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5F00\u901A";
|
|
6454
|
+
var MESSAGE_UNAUTHENTICATED = "\u767B\u5F55\u72B6\u6001\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
|
|
6455
|
+
var MESSAGE_UNREACHABLE = "\u8BB0\u5FC6\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
|
|
6456
|
+
var MESSAGE_NO_GATEWAY = "\u8FD8\u6CA1\u8FDE\u4E0A\u4F01\u4E1A\u7F51\u5173\uFF0C\u8BF7\u5148\u767B\u5F55\u540E\u518D\u8BD5";
|
|
6457
|
+
var MESSAGE_GENERIC = "\u8BB0\u5FC6\u914D\u7F6E\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
|
|
6458
|
+
function messageFor(reason) {
|
|
6459
|
+
switch (reason) {
|
|
6460
|
+
case "not-configured":
|
|
6461
|
+
return MESSAGE_NOT_CONFIGURED;
|
|
6462
|
+
case "unauthenticated":
|
|
6463
|
+
return MESSAGE_UNAUTHENTICATED;
|
|
6464
|
+
case "unreachable":
|
|
6465
|
+
return MESSAGE_UNREACHABLE;
|
|
6466
|
+
case "error":
|
|
6467
|
+
return MESSAGE_GENERIC;
|
|
6468
|
+
}
|
|
6469
|
+
}
|
|
6470
|
+
function failure9(reason, message) {
|
|
6471
|
+
return { ok: false, reason, message: message ?? messageFor(reason) };
|
|
6472
|
+
}
|
|
6473
|
+
function textOrUndefined6(value) {
|
|
6474
|
+
if (typeof value !== "string") return void 0;
|
|
6475
|
+
const text = value.trim();
|
|
6476
|
+
return text === "" ? void 0 : text;
|
|
6477
|
+
}
|
|
6478
|
+
function teamIdsOf(value) {
|
|
6479
|
+
if (!Array.isArray(value)) return [];
|
|
6480
|
+
const ids = [];
|
|
6481
|
+
for (const item of value) {
|
|
6482
|
+
const text = textOrUndefined6(item);
|
|
6483
|
+
if (text !== void 0) ids.push(text);
|
|
6484
|
+
}
|
|
6485
|
+
return ids;
|
|
6486
|
+
}
|
|
6487
|
+
function projectMemoryConfig(body) {
|
|
6488
|
+
const panelUrl = textOrUndefined6(body["panel_url"] ?? body["panelUrl"]);
|
|
6489
|
+
const serviceId = textOrUndefined6(body["service_id"] ?? body["serviceId"]);
|
|
6490
|
+
const apiKey = textOrUndefined6(body["api_key"] ?? body["apiKey"] ?? body["userKey"]);
|
|
6491
|
+
if (panelUrl === void 0 || serviceId === void 0 || apiKey === void 0) return void 0;
|
|
6492
|
+
return {
|
|
6493
|
+
ok: true,
|
|
6494
|
+
panelUrl,
|
|
6495
|
+
serviceId,
|
|
6496
|
+
teamIds: teamIdsOf(body["team_ids"] ?? body["teamIds"]),
|
|
6497
|
+
apiKey
|
|
6498
|
+
};
|
|
6499
|
+
}
|
|
6500
|
+
function errorName2(error) {
|
|
6501
|
+
return error instanceof Error ? error.name : typeof error;
|
|
6502
|
+
}
|
|
6503
|
+
function reasonOfErrorCode(error) {
|
|
6504
|
+
const code = error?.code;
|
|
6505
|
+
if (typeof code !== "string") return void 0;
|
|
6506
|
+
switch (code) {
|
|
6507
|
+
case "SESSION_EXPIRED":
|
|
6508
|
+
return "unauthenticated";
|
|
6509
|
+
case "MEMORY_NOT_CONFIGURED":
|
|
6510
|
+
return "not-configured";
|
|
6511
|
+
case "MEMORY_UNREACHABLE":
|
|
6512
|
+
case "GATEWAY_UNREACHABLE":
|
|
6513
|
+
return "unreachable";
|
|
6514
|
+
default:
|
|
6515
|
+
return void 0;
|
|
6516
|
+
}
|
|
6517
|
+
}
|
|
6518
|
+
function reasonOfStatus(status) {
|
|
6519
|
+
if (status === 401) return "unauthenticated";
|
|
6520
|
+
if (status === 503) return "not-configured";
|
|
6521
|
+
if (status === 504) return "unreachable";
|
|
6522
|
+
return "error";
|
|
6523
|
+
}
|
|
6524
|
+
var MemoryConfigProvider = class {
|
|
6525
|
+
constructor(deps) {
|
|
6526
|
+
this.deps = deps;
|
|
6527
|
+
}
|
|
6528
|
+
cached;
|
|
6529
|
+
async config(opts) {
|
|
6530
|
+
const force = opts?.force === true;
|
|
6531
|
+
let sessionCookie;
|
|
6532
|
+
try {
|
|
6533
|
+
sessionCookie = await this.deps.resolveSession();
|
|
6534
|
+
} catch (error) {
|
|
6535
|
+
this.deps.log?.(`hiwork-core: \u8BB0\u5FC6\u914D\u7F6E\u83B7\u53D6\u5931\u8D25\uFF08${errorName2(error)}\uFF09`);
|
|
6536
|
+
return failure9("error");
|
|
6537
|
+
}
|
|
6538
|
+
if (sessionCookie === void 0 || sessionCookie === "") return failure9("unauthenticated");
|
|
6539
|
+
if (!force) {
|
|
6540
|
+
const hit = this.cached;
|
|
6541
|
+
if (hit !== void 0 && hit.sessionCookie === sessionCookie && this.now() - hit.at < MEMORY_CONFIG_TTL_MS) {
|
|
6542
|
+
return hit.view;
|
|
6543
|
+
}
|
|
6544
|
+
}
|
|
6545
|
+
const gateway = this.deps.gateway();
|
|
6546
|
+
if (gateway === void 0) return failure9("unreachable", MESSAGE_NO_GATEWAY);
|
|
6547
|
+
let outcome;
|
|
6548
|
+
try {
|
|
6549
|
+
outcome = await gateway.getMyMemoryConfig(sessionCookie);
|
|
6550
|
+
} catch (error) {
|
|
6551
|
+
const mapped = reasonOfErrorCode(error);
|
|
6552
|
+
if (mapped !== void 0) return failure9(mapped);
|
|
6553
|
+
this.deps.log?.(`hiwork-core: \u8BB0\u5FC6\u914D\u7F6E\u83B7\u53D6\u5931\u8D25\uFF08${errorName2(error)}\uFF09`);
|
|
6554
|
+
return failure9("error");
|
|
6555
|
+
}
|
|
6556
|
+
if (!outcome.ok) {
|
|
6557
|
+
if (outcome.kind === "network") return failure9("unreachable");
|
|
6558
|
+
if (outcome.kind === "http") return failure9(reasonOfStatus(outcome.status));
|
|
6559
|
+
return failure9("error");
|
|
6560
|
+
}
|
|
6561
|
+
const view = projectMemoryConfig(outcome.body);
|
|
6562
|
+
if (view === void 0) {
|
|
6563
|
+
this.deps.log?.("hiwork-core: \u7F51\u5173\u8FD4\u56DE\u7684 memory-config \u4E0D\u5B8C\u6574\uFF08\u7F3A panel_url / service_id / api_key \u4E4B\u4E00\uFF09");
|
|
6564
|
+
return failure9("error");
|
|
6565
|
+
}
|
|
6566
|
+
this.cached = { sessionCookie, view, at: this.now() };
|
|
6567
|
+
return view;
|
|
6568
|
+
}
|
|
6569
|
+
now() {
|
|
6570
|
+
return (this.deps.now ?? Date.now)();
|
|
6571
|
+
}
|
|
6572
|
+
};
|
|
6573
|
+
function provideMemoryConfig(ctx, deps) {
|
|
6574
|
+
const service = new MemoryConfigProvider(deps);
|
|
6575
|
+
const dispose = ctx.provide(HIWORK_MEMORY_SERVICE, service);
|
|
6576
|
+
return typeof dispose === "function" ? dispose : () => {
|
|
6577
|
+
};
|
|
6578
|
+
}
|
|
6579
|
+
function createMemoryConfigGateway(baseURL, opts = {}) {
|
|
6580
|
+
const root = baseURL.replace(/\/+$/, "");
|
|
6581
|
+
const timeoutMs = opts.timeoutMs ?? MEMORY_CONFIG_TIMEOUT_MS;
|
|
6582
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
6583
|
+
return {
|
|
6584
|
+
async getMyMemoryConfig(sessionCookie) {
|
|
6585
|
+
try {
|
|
6586
|
+
const res = await doFetch(`${root}${ME_MEMORY_CONFIG_PATH}`, {
|
|
6587
|
+
method: "GET",
|
|
6588
|
+
headers: {
|
|
6589
|
+
"user-agent": `hiwork-core/${PKG_VERSION}`,
|
|
6590
|
+
cookie: sessionCookie
|
|
6591
|
+
},
|
|
6592
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
6593
|
+
});
|
|
6594
|
+
if (!res.ok) return { ok: false, kind: "http", status: res.status };
|
|
6595
|
+
let body;
|
|
6596
|
+
try {
|
|
6597
|
+
body = await res.json();
|
|
6598
|
+
} catch {
|
|
6599
|
+
return { ok: false, kind: "invalid" };
|
|
6600
|
+
}
|
|
6601
|
+
if (body === null || typeof body !== "object") return { ok: false, kind: "invalid" };
|
|
6602
|
+
return { ok: true, body };
|
|
6603
|
+
} catch {
|
|
6604
|
+
return { ok: false, kind: "network" };
|
|
6605
|
+
}
|
|
6606
|
+
}
|
|
6607
|
+
};
|
|
6608
|
+
}
|
|
6609
|
+
|
|
6388
6610
|
// src/office-credentials.ts
|
|
6389
6611
|
var OFFICE_CREDENTIALS_SERVICE = "hiworkOfficeCredentials";
|
|
6390
6612
|
var OFFICE_MESSAGE_FALLBACK = {
|
|
@@ -6664,7 +6886,7 @@ function parseDeviceInfo(message) {
|
|
|
6664
6886
|
...version === void 0 || version === "" ? {} : { appVersion: version }
|
|
6665
6887
|
};
|
|
6666
6888
|
}
|
|
6667
|
-
function
|
|
6889
|
+
function errorName3(error) {
|
|
6668
6890
|
return error instanceof Error ? error.name : typeof error;
|
|
6669
6891
|
}
|
|
6670
6892
|
function registerIpcMessageHandler(ports) {
|
|
@@ -6691,12 +6913,12 @@ function registerIpcMessageHandler(ports) {
|
|
|
6691
6913
|
authInFlight = true;
|
|
6692
6914
|
try {
|
|
6693
6915
|
void Promise.resolve(ports.complete(input)).catch((error) => {
|
|
6694
|
-
log(`hiwork-core: \u6D4F\u89C8\u5668\u6388\u6743\u56DE\u8C03\u5904\u7406\u5F02\u5E38\uFF08${
|
|
6916
|
+
log(`hiwork-core: \u6D4F\u89C8\u5668\u6388\u6743\u56DE\u8C03\u5904\u7406\u5F02\u5E38\uFF08${errorName3(error)}\uFF09`);
|
|
6695
6917
|
}).finally(() => {
|
|
6696
6918
|
authInFlight = false;
|
|
6697
6919
|
});
|
|
6698
6920
|
} catch (error) {
|
|
6699
|
-
log(`hiwork-core: \u6D4F\u89C8\u5668\u6388\u6743\u56DE\u8C03\u5206\u53D1\u5931\u8D25\uFF08${
|
|
6921
|
+
log(`hiwork-core: \u6D4F\u89C8\u5668\u6388\u6743\u56DE\u8C03\u5206\u53D1\u5931\u8D25\uFF08${errorName3(error)}\uFF09`);
|
|
6700
6922
|
authInFlight = false;
|
|
6701
6923
|
}
|
|
6702
6924
|
return;
|
|
@@ -6744,12 +6966,12 @@ async function projectLoginMode(opts) {
|
|
|
6744
6966
|
}
|
|
6745
6967
|
}
|
|
6746
6968
|
} catch (error) {
|
|
6747
|
-
opts.log?.(`hiwork-core: \u5BA2\u6237\u7AEF\u7B56\u7565\u62C9\u53D6\u5931\u8D25\uFF0C\u767B\u5F55\u65B9\u5F0F\u56DE\u9000 both\uFF08${
|
|
6969
|
+
opts.log?.(`hiwork-core: \u5BA2\u6237\u7AEF\u7B56\u7565\u62C9\u53D6\u5931\u8D25\uFF0C\u767B\u5F55\u65B9\u5F0F\u56DE\u9000 both\uFF08${errorName3(error)}\uFF09`);
|
|
6748
6970
|
}
|
|
6749
6971
|
try {
|
|
6750
6972
|
await opts.state.patch({ set: { loginMode, passwordLogin } });
|
|
6751
6973
|
} catch (error) {
|
|
6752
|
-
opts.log?.(`hiwork-core: \u5BA2\u6237\u7AEF\u7B56\u7565\u5199\u5165\u5931\u8D25\uFF08${
|
|
6974
|
+
opts.log?.(`hiwork-core: \u5BA2\u6237\u7AEF\u7B56\u7565\u5199\u5165\u5931\u8D25\uFF08${errorName3(error)}\uFF09`);
|
|
6753
6975
|
}
|
|
6754
6976
|
return loginMode;
|
|
6755
6977
|
}
|
|
@@ -6995,6 +7217,22 @@ function apply(ctx, config) {
|
|
|
6995
7217
|
ctx.logger.warn(message);
|
|
6996
7218
|
}
|
|
6997
7219
|
}), "hiwork-core: office credentials");
|
|
7220
|
+
ctx.effect(() => provideIdentity(ctx, {
|
|
7221
|
+
current: () => current(),
|
|
7222
|
+
log: (message) => {
|
|
7223
|
+
ctx.logger.warn(message);
|
|
7224
|
+
}
|
|
7225
|
+
}), "hiwork-core: identity service");
|
|
7226
|
+
ctx.effect(() => provideMemoryConfig(ctx, {
|
|
7227
|
+
resolveSession: async () => (await ports.credentials.resolve(SESSION_REF))?.value,
|
|
7228
|
+
gateway: () => {
|
|
7229
|
+
const baseURL = current().baseURL;
|
|
7230
|
+
return baseURL === void 0 || baseURL === "" ? void 0 : createMemoryConfigGateway(baseURL);
|
|
7231
|
+
},
|
|
7232
|
+
log: (message) => {
|
|
7233
|
+
ctx.logger.warn(message);
|
|
7234
|
+
}
|
|
7235
|
+
}), "hiwork-core: memory config service");
|
|
6998
7236
|
ctx.effect(() => provideOfficeRelay(ctx, {
|
|
6999
7237
|
resolveSession: async () => (await ports.credentials.resolve(SESSION_REF))?.value,
|
|
7000
7238
|
gateway: () => {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆系统配置下发的只读 host 服务(`hiworkMemory`)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:HiWork 给员工接了一套「记忆系统」(腾讯 TencentDB Agent Memory)。规范是
|
|
5
|
+
* **员工零操作**——记忆的服务地址、实例 ID、团队 ID、凭据**全部由网关下发**,插件
|
|
6
|
+
* 不得让员工填任何技术字段。本模块是这条链中间的那一环:
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* hiwork-admin GET /api/me/memory-config ← 网关侧(provision + 下发)
|
|
10
|
+
* ↑ 本文件(hiwork-core,用登录会话调它)
|
|
11
|
+
* hiwork-memory ctx.get('hiworkMemory').config() ← 插件侧
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* 设计要点(形状照 `src/identity.ts`,出网照 `src/ai-router-client.ts`):
|
|
15
|
+
* - **只读、失败不抛异常**:一律返回 `{ ok: false, reason, message }`,reason 四取值
|
|
16
|
+
* `not-configured` / `unauthenticated` / `unreachable` / `error`。失败是**数据**不是
|
|
17
|
+
* 异常:插件侧按 `ok` 判断,绝不会因为宿主的一次网络抖动把异常漏进会话。
|
|
18
|
+
* - **字段名分层只在本文件做**:网关那层是 **snake_case**(`panel_url` /
|
|
19
|
+
* `service_id` / `team_ids` / `api_key`,跨仓冻结契约),本文件把它转成 camelCase 的
|
|
20
|
+
* {@link HiworkMemoryConfigView} 再对外暴露——插件侧**永远不感知上游命名**。
|
|
21
|
+
* - **凭据只在内存里活**:`apiKey` 不写 settings、不写任何 storage、不写任何缓存文件
|
|
22
|
+
* (与 `src/office-credentials.ts` 同口径),也**不进任何日志**(含调试日志与错误
|
|
23
|
+
* 日志)——本模块只记事件与异常类型名,绝不记字段值。
|
|
24
|
+
* - **5 分钟内存缓存**:命中即不出网(省一次往返,也少一次网关侧的 provision 幂等压力);
|
|
25
|
+
* `force: true` 绕过缓存(「重新获取配置」与 401 自愈走这条)。缓存与**会话 cookie
|
|
26
|
+
* 绑定**:换了登录账号(登出再登入)时旧缓存直接失效,绝不把上一个员工的凭据发给
|
|
27
|
+
* 下一个员工(粘错 = 串号)。
|
|
28
|
+
* - **失败不进缓存**:拉不到时下一次调用重新出网——否则一次网关抖动会把员工锁在
|
|
29
|
+
* 「记忆不可用」里整整一个 TTL。
|
|
30
|
+
* - **message 是给非技术员工看的中文**:不含凭据、不含 URL 原文、不含 HTTP 状态码。
|
|
31
|
+
*
|
|
32
|
+
* 本模块不 import cordis 运行时:provide 走结构化的 {@link MemoryConfigContext} 端口,
|
|
33
|
+
* 调用方(index.ts)传真实 ctx;单测直接伪造。
|
|
34
|
+
*/
|
|
35
|
+
import { PKG_VERSION } from "./version.js";
|
|
36
|
+
/** cordis 服务名(hiwork-memory 的 Host 半边按此名 `ctx.get` 探测)。 */
|
|
37
|
+
export const HIWORK_MEMORY_SERVICE = 'hiworkMemory';
|
|
38
|
+
/** 网关侧端点(跨仓冻结;与 `/api/me/office-config` 同一条会话鉴权)。 */
|
|
39
|
+
export const ME_MEMORY_CONFIG_PATH = '/api/me/memory-config';
|
|
40
|
+
/** 成功结果的缓存 TTL:5 分钟。 */
|
|
41
|
+
export const MEMORY_CONFIG_TTL_MS = 5 * 60 * 1000;
|
|
42
|
+
/** 出网超时(与 `AiRouterClient` 的缺省一致)。 */
|
|
43
|
+
export const MEMORY_CONFIG_TIMEOUT_MS = 10_000;
|
|
44
|
+
/** 失败文案(给非技术员工看的中文;不含凭据、URL 原文、HTTP 状态码)。 */
|
|
45
|
+
const MESSAGE_NOT_CONFIGURED = '管理员还没开启团队记忆,请联系管理员开通';
|
|
46
|
+
const MESSAGE_UNAUTHENTICATED = '登录状态已过期,请重新登录';
|
|
47
|
+
const MESSAGE_UNREACHABLE = '记忆服务暂时不可用,请稍后重试';
|
|
48
|
+
const MESSAGE_NO_GATEWAY = '还没连上企业网关,请先登录后再试';
|
|
49
|
+
const MESSAGE_GENERIC = '记忆配置暂时无法获取,请稍后重试';
|
|
50
|
+
/** 按 reason 取文案(未登录的两条路径共用同一句,避免措辞分叉)。 */
|
|
51
|
+
function messageFor(reason) {
|
|
52
|
+
switch (reason) {
|
|
53
|
+
case 'not-configured': return MESSAGE_NOT_CONFIGURED;
|
|
54
|
+
case 'unauthenticated': return MESSAGE_UNAUTHENTICATED;
|
|
55
|
+
case 'unreachable': return MESSAGE_UNREACHABLE;
|
|
56
|
+
case 'error': return MESSAGE_GENERIC;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** 失败视图(**只带 reason 与 message**,绝不带半个字段)。 */
|
|
60
|
+
function failure(reason, message) {
|
|
61
|
+
return { ok: false, reason, message: message ?? messageFor(reason) };
|
|
62
|
+
}
|
|
63
|
+
/** 裁掉两侧空白;非字符串或空白串一律视同缺省。 */
|
|
64
|
+
function textOrUndefined(value) {
|
|
65
|
+
if (typeof value !== 'string')
|
|
66
|
+
return undefined;
|
|
67
|
+
const text = value.trim();
|
|
68
|
+
return text === '' ? undefined : text;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 读团队列表:只保留非空字符串,**保持网关下发的顺序**(按优先级排好的,插件取第一个)。
|
|
72
|
+
* `[]` / 缺省 / 形状非法一律视同「只用个人域」——**不是错误**(spec §4.4 消费规则 3)。
|
|
73
|
+
*/
|
|
74
|
+
function teamIdsOf(value) {
|
|
75
|
+
if (!Array.isArray(value))
|
|
76
|
+
return [];
|
|
77
|
+
const ids = [];
|
|
78
|
+
for (const item of value) {
|
|
79
|
+
const text = textOrUndefined(item);
|
|
80
|
+
if (text !== undefined)
|
|
81
|
+
ids.push(text);
|
|
82
|
+
}
|
|
83
|
+
return ids;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* snake_case → camelCase 投影(**全仓唯一做这层转换的地方**)。
|
|
87
|
+
*
|
|
88
|
+
* 三个必需字段(`panel_url` / `service_id` / `api_key`)任一缺失即返回 undefined:
|
|
89
|
+
* 网关 200 却缺字段 = 契约破坏,宁可报「暂时无法获取」,也绝不把半份配置交给终端
|
|
90
|
+
* (半份配置的下场是插件拿空地址去打请求,错误信息离真正的原因十万八千里)。
|
|
91
|
+
*
|
|
92
|
+
* 同时宽容接受 camelCase 别名(`panelUrl` / `serviceId` / `apiKey` / `userKey`):
|
|
93
|
+
* 兄弟仓的 spec 里这两个名字都出现过(`hiwork-admin` 的
|
|
94
|
+
* `docs/superpowers/specs/2026-09-18-memory-provisioning-design.md` §4.1 写的是 camelCase
|
|
95
|
+
* 且字段名为 `userKey`),读取端宽容不会掩盖错误,却能让本服务在两种落地之间都能工作。
|
|
96
|
+
* **写出永远是 camelCase 视图**——插件侧不感知任何一种上游命名。
|
|
97
|
+
*/
|
|
98
|
+
function projectMemoryConfig(body) {
|
|
99
|
+
const panelUrl = textOrUndefined(body['panel_url'] ?? body['panelUrl']);
|
|
100
|
+
const serviceId = textOrUndefined(body['service_id'] ?? body['serviceId']);
|
|
101
|
+
const apiKey = textOrUndefined(body['api_key'] ?? body['apiKey'] ?? body['userKey']);
|
|
102
|
+
if (panelUrl === undefined || serviceId === undefined || apiKey === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
panelUrl,
|
|
107
|
+
serviceId,
|
|
108
|
+
teamIds: teamIdsOf(body['team_ids'] ?? body['teamIds']),
|
|
109
|
+
apiKey,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** 异常类型名(日志只记类型,不记消息,避免把凭据/URL 带进日志)。 */
|
|
113
|
+
function errorName(error) {
|
|
114
|
+
return error instanceof Error ? error.name : typeof error;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 把「会抛错的网关实现」的稳定 code 映射成 reason。
|
|
118
|
+
*
|
|
119
|
+
* 本模块自带的出网端口({@link createMemoryConfigGateway})**从不抛错**,这一支是为了
|
|
120
|
+
* 容忍接线侧传进来的其它实现(例如将来把本端点加进 `AiRouterClient` 后直接复用
|
|
121
|
+
* `gatewayFor(baseURL)`):按结构化 `code` 字段判别,不 import 对方的类,也不读 message
|
|
122
|
+
* (message 里可能带 URL 原文)。
|
|
123
|
+
*/
|
|
124
|
+
function reasonOfErrorCode(error) {
|
|
125
|
+
const code = error?.code;
|
|
126
|
+
if (typeof code !== 'string')
|
|
127
|
+
return undefined;
|
|
128
|
+
switch (code) {
|
|
129
|
+
case 'SESSION_EXPIRED': return 'unauthenticated';
|
|
130
|
+
case 'MEMORY_NOT_CONFIGURED': return 'not-configured';
|
|
131
|
+
case 'MEMORY_UNREACHABLE':
|
|
132
|
+
case 'GATEWAY_UNREACHABLE': return 'unreachable';
|
|
133
|
+
default: return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** 非 200 状态码 → reason(逐字对应契约:401 / 503 / 504 各有确定语义)。 */
|
|
137
|
+
function reasonOfStatus(status) {
|
|
138
|
+
if (status === 401)
|
|
139
|
+
return 'unauthenticated';
|
|
140
|
+
// 网关 503 `MEMORY_NOT_CONFIGURED`:管理员还没开启团队记忆(部署状态,不是故障)。
|
|
141
|
+
if (status === 503)
|
|
142
|
+
return 'not-configured';
|
|
143
|
+
// 网关 504 `MEMORY_UNREACHABLE`:记忆服务自己暂时不可用——语义上就是「够不着」。
|
|
144
|
+
if (status === 504)
|
|
145
|
+
return 'unreachable';
|
|
146
|
+
return 'error';
|
|
147
|
+
}
|
|
148
|
+
/** 视图投影 + 5 分钟内存缓存(无状态依赖,只有一条内存缓存)。 */
|
|
149
|
+
class MemoryConfigProvider {
|
|
150
|
+
deps;
|
|
151
|
+
cached;
|
|
152
|
+
constructor(deps) {
|
|
153
|
+
this.deps = deps;
|
|
154
|
+
}
|
|
155
|
+
async config(opts) {
|
|
156
|
+
const force = opts?.force === true;
|
|
157
|
+
let sessionCookie;
|
|
158
|
+
try {
|
|
159
|
+
sessionCookie = await this.deps.resolveSession();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
// 凭据服务读取失败是宿主故障,不是「没登录」:如实报 error,不诱导用户反复重登。
|
|
163
|
+
this.deps.log?.(`hiwork-core: 记忆配置获取失败(${errorName(error)})`);
|
|
164
|
+
return failure('error');
|
|
165
|
+
}
|
|
166
|
+
if (sessionCookie === undefined || sessionCookie === '')
|
|
167
|
+
return failure('unauthenticated');
|
|
168
|
+
if (!force) {
|
|
169
|
+
const hit = this.cached;
|
|
170
|
+
if (hit !== undefined && hit.sessionCookie === sessionCookie && this.now() - hit.at < MEMORY_CONFIG_TTL_MS) {
|
|
171
|
+
return hit.view;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const gateway = this.deps.gateway();
|
|
175
|
+
// 没有网关地址 = 还没完成登录(baseURL 是登录时写下的),与网络不通归同一档。
|
|
176
|
+
if (gateway === undefined)
|
|
177
|
+
return failure('unreachable', MESSAGE_NO_GATEWAY);
|
|
178
|
+
let outcome;
|
|
179
|
+
try {
|
|
180
|
+
outcome = await gateway.getMyMemoryConfig(sessionCookie);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const mapped = reasonOfErrorCode(error);
|
|
184
|
+
if (mapped !== undefined)
|
|
185
|
+
return failure(mapped);
|
|
186
|
+
this.deps.log?.(`hiwork-core: 记忆配置获取失败(${errorName(error)})`);
|
|
187
|
+
return failure('error');
|
|
188
|
+
}
|
|
189
|
+
if (!outcome.ok) {
|
|
190
|
+
if (outcome.kind === 'network')
|
|
191
|
+
return failure('unreachable');
|
|
192
|
+
if (outcome.kind === 'http')
|
|
193
|
+
return failure(reasonOfStatus(outcome.status));
|
|
194
|
+
return failure('error');
|
|
195
|
+
}
|
|
196
|
+
const view = projectMemoryConfig(outcome.body);
|
|
197
|
+
if (view === undefined) {
|
|
198
|
+
// 只记事件与字段名,不记任何值(响应体里带凭据)。
|
|
199
|
+
this.deps.log?.('hiwork-core: 网关返回的 memory-config 不完整(缺 panel_url / service_id / api_key 之一)');
|
|
200
|
+
return failure('error');
|
|
201
|
+
}
|
|
202
|
+
// 只缓存成功结果:拉不到时下一次调用重新出网(一次抖动不该锁住一个 TTL)。
|
|
203
|
+
this.cached = { sessionCookie, view, at: this.now() };
|
|
204
|
+
return view;
|
|
205
|
+
}
|
|
206
|
+
now() {
|
|
207
|
+
return (this.deps.now ?? Date.now)();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 提供 host 侧服务 `hiworkMemory`;返回 disposer(交给 `ctx.effect` 管理生命周期,
|
|
212
|
+
* 插件卸载时服务随 fiber 一并注销)。
|
|
213
|
+
*/
|
|
214
|
+
export function provideMemoryConfig(ctx, deps) {
|
|
215
|
+
const service = new MemoryConfigProvider(deps);
|
|
216
|
+
const dispose = ctx.provide(HIWORK_MEMORY_SERVICE, service);
|
|
217
|
+
return typeof dispose === 'function' ? dispose : () => { };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 本端点的出网实现(形状与 `AiRouterClient` 的 `request()` 同款:user-agent 带包版本、
|
|
221
|
+
* 会话 cookie 走 `cookie` 头、超时用 `AbortSignal.timeout`)。
|
|
222
|
+
*
|
|
223
|
+
* **不新造鉴权**:只复用 Host 侧已有的登录会话 cookie(由 `resolveSession` 从凭据库取),
|
|
224
|
+
* 没有任何密钥/令牌在这一层生成或保存。
|
|
225
|
+
*
|
|
226
|
+
* **永不抛异常、绝不回显响应原文**:失败收敛成 {@link MemoryConfigOutcome},日志与
|
|
227
|
+
* 异常里都不会出现 URL、状态码或响应体——响应体里带的是该员工自己的记忆凭据。
|
|
228
|
+
*
|
|
229
|
+
* `fetchImpl` / `timeoutMs` 仅用于测试注入。
|
|
230
|
+
*/
|
|
231
|
+
export function createMemoryConfigGateway(baseURL, opts = {}) {
|
|
232
|
+
const root = baseURL.replace(/\/+$/, '');
|
|
233
|
+
const timeoutMs = opts.timeoutMs ?? MEMORY_CONFIG_TIMEOUT_MS;
|
|
234
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
235
|
+
return {
|
|
236
|
+
async getMyMemoryConfig(sessionCookie) {
|
|
237
|
+
try {
|
|
238
|
+
const res = await doFetch(`${root}${ME_MEMORY_CONFIG_PATH}`, {
|
|
239
|
+
method: 'GET',
|
|
240
|
+
headers: {
|
|
241
|
+
'user-agent': `hiwork-core/${PKG_VERSION}`,
|
|
242
|
+
cookie: sessionCookie,
|
|
243
|
+
},
|
|
244
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok)
|
|
247
|
+
return { ok: false, kind: 'http', status: res.status };
|
|
248
|
+
let body;
|
|
249
|
+
try {
|
|
250
|
+
body = await res.json();
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// 非 JSON 响应:只报「不可解析」,不把响应片段带进任何返回值或日志。
|
|
254
|
+
return { ok: false, kind: 'invalid' };
|
|
255
|
+
}
|
|
256
|
+
if (body === null || typeof body !== 'object')
|
|
257
|
+
return { ok: false, kind: 'invalid' };
|
|
258
|
+
return { ok: true, body };
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// 超时(AbortSignal.timeout → TimeoutError)/ 连接失败 / DNS:一律按不可达。
|
|
262
|
+
return { ok: false, kind: 'network' };
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { GatewayKey } from './ai-router-client.ts';
|
|
2
|
+
/** cordis 服务名(hiwork-memory 等消费方按此名 `ctx.get` 探测)。 */
|
|
3
|
+
export declare const HIWORK_IDENTITY_SERVICE = "hiworkIdentity";
|
|
4
|
+
/** 当前登录身份的稳定视图;未登录是 `{ loggedIn: false }`(其余字段可省略)。 */
|
|
5
|
+
export interface HiworkIdentityView {
|
|
6
|
+
/** 是否已登录(有账号名即视为已登录)。false 时其余字段无意义。 */
|
|
7
|
+
loggedIn: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* 隔离键:`u_` + `sha256(账号名)` 前 16 位 hex。
|
|
10
|
+
* **恒不是账号名原文**;同一账号恒等、不同账号不同。消费方把它当不透明字符串用。
|
|
11
|
+
*/
|
|
12
|
+
subject?: string;
|
|
13
|
+
/** 登录账号(网关 LDAP 账号名);供界面显示,**不得**当隔离键用。 */
|
|
14
|
+
username?: string;
|
|
15
|
+
/** 账号显示名(中文名);命名空间里没有时缺省(界面按既有约定回退 username)。 */
|
|
16
|
+
displayName?: string;
|
|
17
|
+
/** 员工姓名(来自 keys 的 `employee_name`);还没有 keys 或缺省时为 undefined。 */
|
|
18
|
+
employeeName?: string;
|
|
19
|
+
/** 团队名(来自 keys 的 `team_name`);还没有 keys 或缺省时为 undefined。 */
|
|
20
|
+
teamName?: string;
|
|
21
|
+
}
|
|
22
|
+
/** 本服务对宿主的可调用面(只读、同步、永不抛异常)。 */
|
|
23
|
+
export interface HiworkIdentityService {
|
|
24
|
+
/** 当前登录身份的稳定视图;未登录返回 { loggedIn: false }。 */
|
|
25
|
+
current(): HiworkIdentityView;
|
|
26
|
+
}
|
|
27
|
+
/** 身份的原始来源:`ai-router` 命名空间的相关字段 + 最近一次拉取到的 keys 列表。 */
|
|
28
|
+
export interface IdentityState {
|
|
29
|
+
username?: string | undefined;
|
|
30
|
+
/** 登录账号的显示名(网关 LDAP displayName);缺省时视图里也不出现。 */
|
|
31
|
+
displayName?: string | undefined;
|
|
32
|
+
/** 最近一次拉取到的当前用户全部虚拟 Key(`employee_name` / `team_name` 的来源)。 */
|
|
33
|
+
keys?: readonly GatewayKey[] | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Host 侧依赖:当前命名空间快照读取 + 日志出口。 */
|
|
36
|
+
export interface IdentityDeps {
|
|
37
|
+
/**
|
|
38
|
+
* 读一次当前 `ai-router` 命名空间值。**每次 `current()` 都会调用**(本服务不缓存);
|
|
39
|
+
* 传 `() => current()` 即可(`AiRouterNamespaceValue` 结构兼容 {@link IdentityState})。
|
|
40
|
+
*/
|
|
41
|
+
current(): IdentityState;
|
|
42
|
+
/** Host 日志出口(只记错误类型,不记账号名之外的任何凭据)。 */
|
|
43
|
+
log?: ((message: string) => void) | undefined;
|
|
44
|
+
}
|
|
45
|
+
/** `ctx.provide` 的最小端口(结构兼容 cordis Context,便于单测伪造)。 */
|
|
46
|
+
export interface IdentityContext {
|
|
47
|
+
provide(name: string, value?: unknown): unknown;
|
|
48
|
+
}
|
|
49
|
+
/** 视图字段名的语义名(供消费方日志 / 文档引用,避免各处硬编码字符串)。 */
|
|
50
|
+
export declare const IDENTITY_SUBJECT_PREFIX = "u_";
|
|
51
|
+
/**
|
|
52
|
+
* 由登录账号派生稳定 subject:`u_` + `sha256(账号名).hex` 的前 16 位。
|
|
53
|
+
*
|
|
54
|
+
* 输入先裁掉两侧空白(与视图里的 `username` 同口径),因此 `' alice '` 与 `'alice'`
|
|
55
|
+
* 派生同一个 subject;大小写不折叠——账号名在网关侧就是区分大小写的。
|
|
56
|
+
* **本函数绝不返回账号名原文**:subject 会落进存储键 / 目录名,哈希是隔离键
|
|
57
|
+
* 既能稳定标识同一员工、又不外泄账号的那条界线。
|
|
58
|
+
*/
|
|
59
|
+
export declare function deriveSubject(username: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* 提供 host 侧服务 `hiworkIdentity`;返回 disposer(交给 `ctx.effect` 管理生命周期,
|
|
62
|
+
* 插件卸载时服务随 fiber 一并注销)。
|
|
63
|
+
*/
|
|
64
|
+
export declare function provideIdentity(ctx: IdentityContext, deps: IdentityDeps): () => void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** cordis 服务名(hiwork-memory 的 Host 半边按此名 `ctx.get` 探测)。 */
|
|
2
|
+
export declare const HIWORK_MEMORY_SERVICE = "hiworkMemory";
|
|
3
|
+
/** 网关侧端点(跨仓冻结;与 `/api/me/office-config` 同一条会话鉴权)。 */
|
|
4
|
+
export declare const ME_MEMORY_CONFIG_PATH = "/api/me/memory-config";
|
|
5
|
+
/** 成功结果的缓存 TTL:5 分钟。 */
|
|
6
|
+
export declare const MEMORY_CONFIG_TTL_MS: number;
|
|
7
|
+
/** 出网超时(与 `AiRouterClient` 的缺省一致)。 */
|
|
8
|
+
export declare const MEMORY_CONFIG_TIMEOUT_MS = 10000;
|
|
9
|
+
/**
|
|
10
|
+
* 失败原因(稳定枚举,消费方据此决定提示与自愈策略):
|
|
11
|
+
* - `not-configured`:管理员还没开通团队记忆(网关 503 `MEMORY_NOT_CONFIGURED`);
|
|
12
|
+
* - `unauthenticated`:没有可用会话(未登录 / 会话已失效);
|
|
13
|
+
* - `unreachable`:超时、网络不通、网关地址未配置,或记忆服务自身暂时不可用(504);
|
|
14
|
+
* - `error`:其余非 200、响应不符合契约、宿主内部故障等。
|
|
15
|
+
*/
|
|
16
|
+
export type HiworkMemoryFailureReason = 'not-configured' | 'unauthenticated' | 'unreachable' | 'error';
|
|
17
|
+
/**
|
|
18
|
+
* 对外视图(camelCase,插件侧唯一接口面):
|
|
19
|
+
* 成功时 `ok: true` 且带 `panelUrl` / `serviceId` / `teamIds` / `apiKey`;
|
|
20
|
+
* 失败时 `ok: false` 且带 `reason` / `message`,**不带任何半个字段**。
|
|
21
|
+
*/
|
|
22
|
+
export interface HiworkMemoryConfigView {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
panelUrl?: string;
|
|
25
|
+
serviceId?: string;
|
|
26
|
+
teamIds?: string[];
|
|
27
|
+
apiKey?: string;
|
|
28
|
+
reason?: HiworkMemoryFailureReason;
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
31
|
+
/** 本服务对宿主的可调用面(只读、永不抛异常)。 */
|
|
32
|
+
export interface HiworkMemoryService {
|
|
33
|
+
/** 取当前登录员工的记忆配置;force=true 绕过缓存(用于「重新获取配置」与 401 自愈)。 */
|
|
34
|
+
config(opts?: {
|
|
35
|
+
force?: boolean;
|
|
36
|
+
}): Promise<HiworkMemoryConfigView>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 网关侧原始响应(**snake_case**,跨仓冻结契约;本文件是唯一读它的地方)。
|
|
40
|
+
* 仅供文档与类型自证——真正的读取走宽容投影 {@link projectMemoryConfig}。
|
|
41
|
+
*/
|
|
42
|
+
export interface GatewayMemoryConfig {
|
|
43
|
+
panel_url: string;
|
|
44
|
+
service_id: string;
|
|
45
|
+
/** 按优先级排好;**空数组 / 缺省 = 只用个人域**,不是错误。 */
|
|
46
|
+
team_ids?: string[];
|
|
47
|
+
api_key: string;
|
|
48
|
+
provisioned_at?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 传输层结果:**失败是数据而不是异常**(本模块不依赖调用方捕获异常来判失败)。
|
|
52
|
+
* - `kind: 'http'`:拿到了非 200 状态码;
|
|
53
|
+
* - `kind: 'network'`:超时 / 连接失败 / DNS / 请求被中断;
|
|
54
|
+
* - `kind: 'invalid'`:200 但响应体不是可用的 JSON 对象(契约破坏)。
|
|
55
|
+
*/
|
|
56
|
+
export type MemoryConfigOutcome = {
|
|
57
|
+
readonly ok: true;
|
|
58
|
+
readonly body: unknown;
|
|
59
|
+
} | {
|
|
60
|
+
readonly ok: false;
|
|
61
|
+
readonly kind: 'http';
|
|
62
|
+
readonly status: number;
|
|
63
|
+
} | {
|
|
64
|
+
readonly ok: false;
|
|
65
|
+
readonly kind: 'network';
|
|
66
|
+
} | {
|
|
67
|
+
readonly ok: false;
|
|
68
|
+
readonly kind: 'invalid';
|
|
69
|
+
};
|
|
70
|
+
/** 网关客户端的 memory-config 子集:只声明本模块真正调用的方法(便于单测打桩计数)。 */
|
|
71
|
+
export interface MemoryConfigGatewayPort {
|
|
72
|
+
/** 调 `GET /api/me/memory-config`,返回 snake_case 原始响应或一个稳定失败原因。 */
|
|
73
|
+
getMyMemoryConfig(sessionCookie: string): Promise<MemoryConfigOutcome>;
|
|
74
|
+
}
|
|
75
|
+
/** Host 侧取数依赖:会话解析 + 当前 baseURL 下的网关出网端口。 */
|
|
76
|
+
export interface MemoryConfigDeps {
|
|
77
|
+
/**
|
|
78
|
+
* 解析网关会话 cookie。未登录 / 会话缺失返回 undefined。
|
|
79
|
+
* **返回值只在本模块内部使用**(缓存判等 + 出网),绝不出现在视图与日志里。
|
|
80
|
+
*/
|
|
81
|
+
resolveSession(): Promise<string | undefined>;
|
|
82
|
+
/** 当前 baseURL 对应的网关出网端口;baseURL 未配置时返回 undefined。 */
|
|
83
|
+
gateway(): MemoryConfigGatewayPort | undefined;
|
|
84
|
+
/** Host 日志出口(只记事件与异常类型名,绝不记凭据 / URL / 响应内容)。 */
|
|
85
|
+
log?: ((message: string) => void) | undefined;
|
|
86
|
+
/** 时钟注入(测试用);缺省 `Date.now`。 */
|
|
87
|
+
now?: (() => number) | undefined;
|
|
88
|
+
}
|
|
89
|
+
/** `ctx.provide` 的最小端口(结构兼容 cordis Context,便于单测伪造)。 */
|
|
90
|
+
export interface MemoryConfigContext {
|
|
91
|
+
provide(name: string, value?: unknown): unknown;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 提供 host 侧服务 `hiworkMemory`;返回 disposer(交给 `ctx.effect` 管理生命周期,
|
|
95
|
+
* 插件卸载时服务随 fiber 一并注销)。
|
|
96
|
+
*/
|
|
97
|
+
export declare function provideMemoryConfig(ctx: MemoryConfigContext, deps: MemoryConfigDeps): () => void;
|
|
98
|
+
/**
|
|
99
|
+
* 本端点的出网实现(形状与 `AiRouterClient` 的 `request()` 同款:user-agent 带包版本、
|
|
100
|
+
* 会话 cookie 走 `cookie` 头、超时用 `AbortSignal.timeout`)。
|
|
101
|
+
*
|
|
102
|
+
* **不新造鉴权**:只复用 Host 侧已有的登录会话 cookie(由 `resolveSession` 从凭据库取),
|
|
103
|
+
* 没有任何密钥/令牌在这一层生成或保存。
|
|
104
|
+
*
|
|
105
|
+
* **永不抛异常、绝不回显响应原文**:失败收敛成 {@link MemoryConfigOutcome},日志与
|
|
106
|
+
* 异常里都不会出现 URL、状态码或响应体——响应体里带的是该员工自己的记忆凭据。
|
|
107
|
+
*
|
|
108
|
+
* `fetchImpl` / `timeoutMs` 仅用于测试注入。
|
|
109
|
+
*/
|
|
110
|
+
export declare function createMemoryConfigGateway(baseURL: string, opts?: {
|
|
111
|
+
timeoutMs?: number;
|
|
112
|
+
fetchImpl?: typeof fetch;
|
|
113
|
+
}): MemoryConfigGatewayPort;
|