hiwork-core 0.0.18 → 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/client.js +380 -2
- package/lib/identity.js +111 -0
- package/lib/index.js +1109 -13
- package/lib/memory-config.js +266 -0
- package/lib/pms-client.js +572 -0
- package/lib/pms-todo-bridge.js +640 -0
- package/lib/types/client/contracts.d.ts +22 -0
- package/lib/types/client/pms-todo.d.ts +357 -0
- package/lib/types/identity.d.ts +64 -0
- package/lib/types/memory-config.d.ts +113 -0
- package/lib/types/pms-client.d.ts +293 -0
- package/lib/types/pms-todo-bridge.d.ts +426 -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/client.js
CHANGED
|
@@ -3757,6 +3757,361 @@ var ManagedWorkbenchStore = class {
|
|
|
3757
3757
|
}
|
|
3758
3758
|
};
|
|
3759
3759
|
|
|
3760
|
+
// src/client/pms-todo.ts
|
|
3761
|
+
var PMS_TODO_CHANNEL = "/hiwork-pms-todo";
|
|
3762
|
+
var PmsTodoRpcError = class extends Error {
|
|
3763
|
+
constructor(code, message) {
|
|
3764
|
+
super(message);
|
|
3765
|
+
this.code = code;
|
|
3766
|
+
this.name = "PmsTodoRpcError";
|
|
3767
|
+
}
|
|
3768
|
+
};
|
|
3769
|
+
var GENERIC_FAILURE_MESSAGE4 = "PMS \u5F85\u529E\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
|
|
3770
|
+
var INVALID_RESPONSE_MESSAGE4 = "PMS \u5F85\u529E\u4E3B\u673A\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\u3002";
|
|
3771
|
+
var INTERNAL_CODE = "INTERNAL";
|
|
3772
|
+
function unwrapPmsTodoResult(value) {
|
|
3773
|
+
if (typeof value !== "object" || value === null || !("ok" in value)) {
|
|
3774
|
+
throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3775
|
+
}
|
|
3776
|
+
const result = value;
|
|
3777
|
+
if (result.ok === true) return result.value;
|
|
3778
|
+
if (result.ok === false) {
|
|
3779
|
+
const code = typeof result.error?.code === "string" ? result.error.code : INTERNAL_CODE;
|
|
3780
|
+
const message = typeof result.error?.message === "string" && result.error.message.trim() !== "" ? result.error.message : GENERIC_FAILURE_MESSAGE4;
|
|
3781
|
+
throw new PmsTodoRpcError(code, message);
|
|
3782
|
+
}
|
|
3783
|
+
throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3784
|
+
}
|
|
3785
|
+
function text5(value) {
|
|
3786
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
3787
|
+
}
|
|
3788
|
+
function isRecord3(value) {
|
|
3789
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3790
|
+
}
|
|
3791
|
+
function countOf(value) {
|
|
3792
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
3793
|
+
}
|
|
3794
|
+
function isFiniteCount(value) {
|
|
3795
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
3796
|
+
}
|
|
3797
|
+
function countOr(value, fallback) {
|
|
3798
|
+
return isFiniteCount(value) ? countOf(value) : fallback;
|
|
3799
|
+
}
|
|
3800
|
+
function toItem(value) {
|
|
3801
|
+
if (!isRecord3(value)) return void 0;
|
|
3802
|
+
const id = value["id"];
|
|
3803
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id)) return void 0;
|
|
3804
|
+
const item = {
|
|
3805
|
+
id,
|
|
3806
|
+
title: text5(value["title"]) ?? "",
|
|
3807
|
+
status: text5(value["status"]) ?? ""
|
|
3808
|
+
};
|
|
3809
|
+
const description = text5(value["description"]);
|
|
3810
|
+
if (description !== void 0) item.description = description;
|
|
3811
|
+
const category = text5(value["category"]);
|
|
3812
|
+
if (category !== void 0) item.category = category;
|
|
3813
|
+
const priority = text5(value["priority"]);
|
|
3814
|
+
if (priority !== void 0) item.priority = priority;
|
|
3815
|
+
const startDate = text5(value["startDate"]);
|
|
3816
|
+
if (startDate !== void 0) item.startDate = startDate;
|
|
3817
|
+
const dueDate = text5(value["dueDate"]);
|
|
3818
|
+
if (dueDate !== void 0) item.dueDate = dueDate;
|
|
3819
|
+
const completedTime = text5(value["completedTime"]);
|
|
3820
|
+
if (completedTime !== void 0) item.completedTime = completedTime;
|
|
3821
|
+
if (typeof value["isOverdue"] === "boolean") item.isOverdue = value["isOverdue"];
|
|
3822
|
+
if (typeof value["isDueSoon"] === "boolean") item.isDueSoon = value["isDueSoon"];
|
|
3823
|
+
return item;
|
|
3824
|
+
}
|
|
3825
|
+
function toStatus(value) {
|
|
3826
|
+
if (!isRecord3(value)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3827
|
+
const username = text5(value["username"]);
|
|
3828
|
+
const expiresAt = text5(value["expiresAt"]);
|
|
3829
|
+
return {
|
|
3830
|
+
configured: value["configured"] === true,
|
|
3831
|
+
loggedIn: value["loggedIn"] === true,
|
|
3832
|
+
...username === void 0 ? {} : { username },
|
|
3833
|
+
...expiresAt === void 0 ? {} : { expiresAt }
|
|
3834
|
+
};
|
|
3835
|
+
}
|
|
3836
|
+
function toToday(value) {
|
|
3837
|
+
if (!isRecord3(value)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3838
|
+
const rawItems = value["items"];
|
|
3839
|
+
if (!Array.isArray(rawItems)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3840
|
+
const items = [];
|
|
3841
|
+
for (const raw of rawItems) {
|
|
3842
|
+
const item = toItem(raw);
|
|
3843
|
+
if (item !== void 0) items.push(item);
|
|
3844
|
+
}
|
|
3845
|
+
return { items, fetchedAt: text5(value["fetchedAt"]) ?? "" };
|
|
3846
|
+
}
|
|
3847
|
+
function toStatistics(value) {
|
|
3848
|
+
if (!isRecord3(value)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3849
|
+
return {
|
|
3850
|
+
total: countOf(value["total"]),
|
|
3851
|
+
pending: countOf(value["pending"]),
|
|
3852
|
+
completed: countOf(value["completed"]),
|
|
3853
|
+
overdue: countOf(value["overdue"])
|
|
3854
|
+
};
|
|
3855
|
+
}
|
|
3856
|
+
function toSetStatusResult(value) {
|
|
3857
|
+
if (!isRecord3(value)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3858
|
+
const id = value["id"];
|
|
3859
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id)) {
|
|
3860
|
+
throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3861
|
+
}
|
|
3862
|
+
const status = value["status"];
|
|
3863
|
+
if (status !== "PENDING" && status !== "COMPLETED") {
|
|
3864
|
+
throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3865
|
+
}
|
|
3866
|
+
const completedTime = text5(value["completedTime"]);
|
|
3867
|
+
return { id, status, ...completedTime === void 0 ? {} : { completedTime } };
|
|
3868
|
+
}
|
|
3869
|
+
function isInteger(value) {
|
|
3870
|
+
return typeof value === "number" && Number.isSafeInteger(value);
|
|
3871
|
+
}
|
|
3872
|
+
function toTaskItem(value) {
|
|
3873
|
+
if (!isRecord3(value)) return void 0;
|
|
3874
|
+
const id = value["id"];
|
|
3875
|
+
if (!isInteger(id)) return void 0;
|
|
3876
|
+
const numeric = (raw) => typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
|
3877
|
+
const item = {
|
|
3878
|
+
id,
|
|
3879
|
+
taskName: text5(value["taskName"]) ?? "",
|
|
3880
|
+
description: text5(value["description"]) ?? "",
|
|
3881
|
+
projectId: numeric(value["projectId"]),
|
|
3882
|
+
projectName: text5(value["projectName"]) ?? "",
|
|
3883
|
+
assigneeId: numeric(value["assigneeId"]),
|
|
3884
|
+
assigneeName: text5(value["assigneeName"]) ?? "",
|
|
3885
|
+
deadline: text5(value["deadline"]) ?? "",
|
|
3886
|
+
status: numeric(value["status"]),
|
|
3887
|
+
statusName: text5(value["statusName"]) ?? "",
|
|
3888
|
+
priority: numeric(value["priority"]),
|
|
3889
|
+
priorityName: text5(value["priorityName"]) ?? "",
|
|
3890
|
+
taskType: text5(value["taskType"]) ?? "",
|
|
3891
|
+
taskTypeName: text5(value["taskTypeName"]) ?? "",
|
|
3892
|
+
auditStatus: numeric(value["auditStatus"]),
|
|
3893
|
+
auditStatusName: text5(value["auditStatusName"]) ?? ""
|
|
3894
|
+
};
|
|
3895
|
+
const projectStageName = text5(value["projectStageName"]);
|
|
3896
|
+
if (projectStageName !== void 0) item.projectStageName = projectStageName;
|
|
3897
|
+
const requirementName = text5(value["requirementName"]);
|
|
3898
|
+
if (requirementName !== void 0) item.requirementName = requirementName;
|
|
3899
|
+
const moduleName = text5(value["moduleName"]);
|
|
3900
|
+
if (moduleName !== void 0) item.moduleName = moduleName;
|
|
3901
|
+
const estimatedHours = value["estimatedHours"];
|
|
3902
|
+
if (typeof estimatedHours === "number" && Number.isFinite(estimatedHours)) item.estimatedHours = estimatedHours;
|
|
3903
|
+
const actualHours = value["actualHours"];
|
|
3904
|
+
if (typeof actualHours === "number" && Number.isFinite(actualHours)) item.actualHours = actualHours;
|
|
3905
|
+
return item;
|
|
3906
|
+
}
|
|
3907
|
+
function toTaskList(value) {
|
|
3908
|
+
if (!isRecord3(value)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3909
|
+
const rawItems = value["items"];
|
|
3910
|
+
if (!Array.isArray(rawItems)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3911
|
+
const items = [];
|
|
3912
|
+
for (const raw of rawItems) {
|
|
3913
|
+
const item = toTaskItem(raw);
|
|
3914
|
+
if (item !== void 0) items.push(item);
|
|
3915
|
+
}
|
|
3916
|
+
const current = countOf(value["current"]);
|
|
3917
|
+
return {
|
|
3918
|
+
items,
|
|
3919
|
+
total: countOr(value["total"], items.length),
|
|
3920
|
+
current: isFiniteCount(value["current"]) ? current : 1,
|
|
3921
|
+
size: countOr(value["size"], items.length)
|
|
3922
|
+
};
|
|
3923
|
+
}
|
|
3924
|
+
function toTaskCompleteResult(value) {
|
|
3925
|
+
if (!isRecord3(value)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3926
|
+
const id = value["id"];
|
|
3927
|
+
const status = value["status"];
|
|
3928
|
+
if (!isInteger(id) || !isInteger(status)) throw new PmsTodoRpcError(INTERNAL_CODE, INVALID_RESPONSE_MESSAGE4);
|
|
3929
|
+
return { id, status };
|
|
3930
|
+
}
|
|
3931
|
+
var PmsTodoStore = class {
|
|
3932
|
+
constructor(rpc) {
|
|
3933
|
+
this.rpc = rpc;
|
|
3934
|
+
}
|
|
3935
|
+
current;
|
|
3936
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3937
|
+
/**
|
|
3938
|
+
* 在飞请求**连同它所属的世代与端点**一起记账。
|
|
3939
|
+
*
|
|
3940
|
+
* 世代:`clear()`(登出 / 换账号)之后,账上那条是**上一个人**发起的,它的待办属于上一个人
|
|
3941
|
+
* ——复用它等于让新账号看到上一个人的待办。端点:`today` 与 `statistics` 的返回值形状不同,
|
|
3942
|
+
* 只有同一个端点的并发调用才允许合并。
|
|
3943
|
+
*/
|
|
3944
|
+
inFlight;
|
|
3945
|
+
/** 世代号:`clear()` 每调用一次 +1,用来作废它之前发起的在飞请求。 */
|
|
3946
|
+
generation = 0;
|
|
3947
|
+
/** 是否已经为「镜像还空着」的情况探过一次 `status`(避免每次同步读都发一次 RPC)。 */
|
|
3948
|
+
statusProbed = false;
|
|
3949
|
+
/**
|
|
3950
|
+
* 本地镜像(同步)。从没同步过返回 `undefined`。
|
|
3951
|
+
*
|
|
3952
|
+
* ⚠️ 本方法有一次**惰性探测**:镜像还空着且从没探过时,它顺手发起一次 `status` 端点调用
|
|
3953
|
+
* (结果到达后写镜像 + 通知订阅者)。理由——渲染是同步的,而「配没配凭据」只有主机知道;
|
|
3954
|
+
* 没有这一步,`status` 端点就永远没有调用方,界面也只能靠一次失败的 `today()` 去反推。
|
|
3955
|
+
* 因此调用方看到 `undefined` **不是**「未配置」,而是「还不知道」:必须订阅。
|
|
3956
|
+
*/
|
|
3957
|
+
status() {
|
|
3958
|
+
if (this.current === void 0 && !this.statusProbed) {
|
|
3959
|
+
this.statusProbed = true;
|
|
3960
|
+
void this.refreshStatus().catch(() => {
|
|
3961
|
+
});
|
|
3962
|
+
}
|
|
3963
|
+
return this.current;
|
|
3964
|
+
}
|
|
3965
|
+
/**
|
|
3966
|
+
* 显式去主机取一次状态并写镜像(`status()` 的异步入口)。
|
|
3967
|
+
*
|
|
3968
|
+
* 失败**不改镜像**(保留上一次已知状态),也**不在镜像里记错误**:错误属于这一次调用,
|
|
3969
|
+
* 调用方(或下一次 `today()`)自己决定怎么展示。
|
|
3970
|
+
*/
|
|
3971
|
+
async refreshStatus(signal) {
|
|
3972
|
+
const { value, generation } = await this.call("status", "status", {}, toStatus, signal);
|
|
3973
|
+
return this.adopt(value, generation);
|
|
3974
|
+
}
|
|
3975
|
+
/**
|
|
3976
|
+
* 登录(凭据交给 Host;**本层不留存密码、不写任何缓存**)。
|
|
3977
|
+
*
|
|
3978
|
+
* 成功时把返回的状态写进镜像——登录是「身份刚变过」,镜像必须跟着变。
|
|
3979
|
+
*/
|
|
3980
|
+
async login(input) {
|
|
3981
|
+
const payload = { username: input.username, password: input.password };
|
|
3982
|
+
if (input.remember !== void 0) payload["remember"] = input.remember;
|
|
3983
|
+
const { value, generation } = await this.call("login", "login", payload, toStatus);
|
|
3984
|
+
return this.adopt(value, generation);
|
|
3985
|
+
}
|
|
3986
|
+
/** 登出(Host 侧清凭据);返回登出后的状态并写镜像。 */
|
|
3987
|
+
async logout() {
|
|
3988
|
+
const { value, generation } = await this.call("logout", "logout", {}, toStatus);
|
|
3989
|
+
return this.adopt(value, generation);
|
|
3990
|
+
}
|
|
3991
|
+
/**
|
|
3992
|
+
* 今日待办。
|
|
3993
|
+
*
|
|
3994
|
+
* 不做本地缓存(返回**刚拉到的**那一份):待办的时效性就是它的全部价值,把上一次的结果当
|
|
3995
|
+
* 「今日」交出去正是这个功能最容易犯的错;界面要离线态时自己留一份 + 标陈旧(spec §6)。
|
|
3996
|
+
*/
|
|
3997
|
+
async today(signal) {
|
|
3998
|
+
const { value } = await this.call("today", "today", {}, toToday, signal);
|
|
3999
|
+
return value;
|
|
4000
|
+
}
|
|
4001
|
+
/** total / pending / completed / overdue 计数。 */
|
|
4002
|
+
async statistics(signal) {
|
|
4003
|
+
const { value } = await this.call("statistics", "statistics", {}, toStatistics, signal);
|
|
4004
|
+
return value;
|
|
4005
|
+
}
|
|
4006
|
+
/**
|
|
4007
|
+
* 勾完成 / 取消完成。
|
|
4008
|
+
*
|
|
4009
|
+
* 返回值里带服务端回声的 id / status(`PmsTodoFace.setStatus` 折成 `void`;面里不需要它,
|
|
4010
|
+
* 但仓库保留着——回滚那一条待办时用它认人最稳)。
|
|
4011
|
+
*/
|
|
4012
|
+
async setStatus(input) {
|
|
4013
|
+
const { value } = await this.call("setStatus", "setStatus", { id: input.id, status: input.status }, toSetStatusResult);
|
|
4014
|
+
return value;
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* **我的未完成项目任务**(spec §3 D7):`myTasks` 端点的取数。
|
|
4018
|
+
*
|
|
4019
|
+
* 入参是**空对象**,没有 `userId`——「谁的任务」由 Host 从凭据记录里取(客户端连自己的
|
|
4020
|
+
* PMS userId 都拿不到,这是 §3 D6 形状的一部分)。
|
|
4021
|
+
*
|
|
4022
|
+
* 与 `today` 同款:**不做本地缓存**(返回刚拉到的那一份),但**并发调用合并**(同一时刻问两遍
|
|
4023
|
+
* 没有意义;世代判据同其他端点,换账号之后不复用)。
|
|
4024
|
+
*/
|
|
4025
|
+
async myTasks(signal) {
|
|
4026
|
+
const { value } = await this.call("myTasks", "myTasks", {}, toTaskList, signal);
|
|
4027
|
+
return value;
|
|
4028
|
+
}
|
|
4029
|
+
/**
|
|
4030
|
+
* **完成一条项目任务**(spec §3 D7)。
|
|
4031
|
+
*
|
|
4032
|
+
* 返回值里带服务端回声的 id / status(`PmsTodoFace.completeTask` 折成 `void`;仓库保留着
|
|
4033
|
+
* ——回滚那一条任务时用它认人最稳)。缺权限时抛 `PmsTodoRpcError('FORBIDDEN')`。
|
|
4034
|
+
*
|
|
4035
|
+
* ⚠️ **刻意不走 {@link PmsTodoStore.call}**(即不参与并发合并):`dedupe` 的键是**端点名**,
|
|
4036
|
+
* 而这是带参数的命令——把它并进同一格会让「几乎同时完成 A 和 B」的第二次调用**复用第一次的
|
|
4037
|
+
* 结果**,用户以为 B 完成了,其实请求根本没发出去。Host 侧的 `completeTask` 同样不合并
|
|
4038
|
+
* (那里也注明「点了两次就是两次动作」)。
|
|
4039
|
+
*/
|
|
4040
|
+
async completeTask(input) {
|
|
4041
|
+
const raw = await this.rpc.call(PMS_TODO_CHANNEL, "completeTask", { id: input.id });
|
|
4042
|
+
return toTaskCompleteResult(unwrapPmsTodoResult(raw));
|
|
4043
|
+
}
|
|
4044
|
+
/** 订阅镜像变化(右栏 tab 据此重渲染)。返回退订函数。 */
|
|
4045
|
+
subscribe(listener) {
|
|
4046
|
+
this.listeners.add(listener);
|
|
4047
|
+
return () => {
|
|
4048
|
+
this.listeners.delete(listener);
|
|
4049
|
+
};
|
|
4050
|
+
}
|
|
4051
|
+
/**
|
|
4052
|
+
* 清空镜像(登出 / 换账号:状态是**按人**的,留着会串号),并**作废在飞的请求**。
|
|
4053
|
+
*
|
|
4054
|
+
* 只清镜像是不够的:清空那一刻正有一次拉取在飞时,那次拉取是**上一个人**发起的。所以世代
|
|
4055
|
+
* +1:那之后的调用者一律不复用旧世代的那条,自己重新发一次 RPC。用世代号而**不是**把
|
|
4056
|
+
* `inFlight` 直接抹成 undefined,理由与 `src/client/managed-workbench.ts` 的同名方法逐字相同
|
|
4057
|
+
* (旧 promise 的 `finally` 会回头把新发起的那条记账清掉,两者互相踩)。
|
|
4058
|
+
*
|
|
4059
|
+
* 同时把 `statusProbed` 复位:新账号的镜像必须能再探一次,否则换账号后 `status()` 会一直
|
|
4060
|
+
* 返回 `undefined` 且再也不去问主机。
|
|
4061
|
+
*/
|
|
4062
|
+
clear() {
|
|
4063
|
+
this.generation += 1;
|
|
4064
|
+
this.current = void 0;
|
|
4065
|
+
this.statusProbed = false;
|
|
4066
|
+
this.emit();
|
|
4067
|
+
}
|
|
4068
|
+
/**
|
|
4069
|
+
* 发一次 RPC、解开信封、过第二道形状校验。
|
|
4070
|
+
*
|
|
4071
|
+
* 返回值里带上**发起时的世代**:只有它等于当前世代时,调用方才写镜像(见 {@link adopt})。
|
|
4072
|
+
*/
|
|
4073
|
+
async call(key, endpoint, payload, parse, signal) {
|
|
4074
|
+
return await this.dedupe(key, async (generation) => {
|
|
4075
|
+
const raw = await this.rpc.call(PMS_TODO_CHANNEL, endpoint, payload, signal);
|
|
4076
|
+
return { value: parse(unwrapPmsTodoResult(raw)), generation };
|
|
4077
|
+
});
|
|
4078
|
+
}
|
|
4079
|
+
/**
|
|
4080
|
+
* 并发合并:同一世代 + 同一端点撞在一起(面板挂载与手动刷新常常同拍)时共用一次请求,
|
|
4081
|
+
* 且**共用同一个返回值**——两边对「这一次拉到了什么」的判断必须一致。
|
|
4082
|
+
*/
|
|
4083
|
+
async dedupe(key, task) {
|
|
4084
|
+
const inFlight = this.inFlight;
|
|
4085
|
+
if (inFlight !== void 0 && inFlight.generation === this.generation && inFlight.key === key) {
|
|
4086
|
+
return await inFlight.task;
|
|
4087
|
+
}
|
|
4088
|
+
const generation = this.generation;
|
|
4089
|
+
const promise = task(generation);
|
|
4090
|
+
this.inFlight = { generation, key, task: promise };
|
|
4091
|
+
try {
|
|
4092
|
+
return await promise;
|
|
4093
|
+
} finally {
|
|
4094
|
+
if (this.inFlight?.task === promise) this.inFlight = void 0;
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
/**
|
|
4098
|
+
* 采纳一份成功状态:写镜像 + 通知订阅者。
|
|
4099
|
+
*
|
|
4100
|
+
* 世代不符的那一份**不写镜像**(但仍原样返回给调用方):它是 `clear()` 之前发起的请求,
|
|
4101
|
+
* 属于上一个人。允许它写回等于「清空之后又被上一个人的状态占住」,而换账号时界面正好靠
|
|
4102
|
+
* `status()` 判断该显示引导还是列表。
|
|
4103
|
+
*/
|
|
4104
|
+
adopt(value, generation) {
|
|
4105
|
+
if (generation !== this.generation) return value;
|
|
4106
|
+
this.current = value;
|
|
4107
|
+
this.emit();
|
|
4108
|
+
return value;
|
|
4109
|
+
}
|
|
4110
|
+
emit() {
|
|
4111
|
+
for (const listener of [...this.listeners]) listener();
|
|
4112
|
+
}
|
|
4113
|
+
};
|
|
4114
|
+
|
|
3760
4115
|
// src/client/index.ts
|
|
3761
4116
|
var inject = ["slots", "locale", "remote", "remote.credentials", "settingsScope", "connection"];
|
|
3762
4117
|
var INTERACTION_STYLE_ID = "hiwork-core-interaction-styles";
|
|
@@ -3797,6 +4152,7 @@ function apply(ctx) {
|
|
|
3797
4152
|
const managedMcp = new ManagedMcpStore(connection.rpc);
|
|
3798
4153
|
const managedSkills = new ManagedSkillsStore(connection.rpc);
|
|
3799
4154
|
const managedWorkbench = new ManagedWorkbenchStore(connection.rpc);
|
|
4155
|
+
const pmsTodo = new PmsTodoStore(connection.rpc);
|
|
3800
4156
|
const appVersion = createAppVersion(connection.rpc);
|
|
3801
4157
|
const pluginUpdates = createPluginUpdates(connection.rpc);
|
|
3802
4158
|
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
@@ -3818,6 +4174,7 @@ function apply(ctx) {
|
|
|
3818
4174
|
managedMcp.clear();
|
|
3819
4175
|
managedSkills.clear();
|
|
3820
4176
|
managedWorkbench.clear();
|
|
4177
|
+
pmsTodo.clear();
|
|
3821
4178
|
}), "hiwork-core: \u7528\u91CF\u7F13\u5B58\u968F\u8D26\u53F7 / Key \u53D8\u5316\u6E05\u7A7A");
|
|
3822
4179
|
ctx.effect(() => observeAboutNavIcon(), "hiwork-core: about nav icon");
|
|
3823
4180
|
ctx.slots.inject("sidebar.brand.mark", () => ctx.slots.register({
|
|
@@ -3836,9 +4193,9 @@ function apply(ctx) {
|
|
|
3836
4193
|
}, UserBadge));
|
|
3837
4194
|
ctx.effect(() => mountLoginGate(scope, storeCredential), "hiwork-core: login gate");
|
|
3838
4195
|
ctx.effect(() => observeHeroDecorations(document, dailyQuotes), "hiwork-core: hero decorations");
|
|
3839
|
-
installFeatureCenter(ctx, managedMcp, managedSkills, managedWorkbench);
|
|
4196
|
+
installFeatureCenter(ctx, managedMcp, managedSkills, managedWorkbench, pmsTodo);
|
|
3840
4197
|
}
|
|
3841
|
-
function installFeatureCenter(ctx, managedMcp, managedSkills, managedWorkbench) {
|
|
4198
|
+
function installFeatureCenter(ctx, managedMcp, managedSkills, managedWorkbench, pmsTodo) {
|
|
3842
4199
|
const featureRegistry = createFeatureRegistry();
|
|
3843
4200
|
let center;
|
|
3844
4201
|
const sessions = ctx.get("sessions");
|
|
@@ -3888,6 +4245,27 @@ function installFeatureCenter(ctx, managedMcp, managedSkills, managedWorkbench)
|
|
|
3888
4245
|
warm: async (signal) => await managedWorkbench.warm(signal),
|
|
3889
4246
|
refresh: async (signal) => await managedWorkbench.refresh(signal),
|
|
3890
4247
|
subscribe: (listener) => managedWorkbench.subscribe(listener)
|
|
4248
|
+
},
|
|
4249
|
+
// PMS 个人待办面:工作台域(hiwork-workbench)按这个成员判断「有没有这个能力」,老 core 上
|
|
4250
|
+
// 它是 undefined,右栏 tab 降级成「请升级 HiWork 客户端」(spec §6 的降级矩阵第一行)。
|
|
4251
|
+
// 七项与 spec §4.3 逐字对应;核心**只发端点调用**——凭据与 token 全在 Host(§3 D6)。
|
|
4252
|
+
// `setStatus` 在面里折成 void(§4.3 冻结的形状),仓库返回值留给诊断与测试。
|
|
4253
|
+
// `myTasks` / `completeTask` 是 §3 D7 追加的两项,**在面里也是可选成员**(旧 core 只有前七项,
|
|
4254
|
+
// 消费方按 `face.pmsTodo.myTasks !== undefined` 判断,见 src/client/pms-todo.ts 的版本闸口)。
|
|
4255
|
+
pmsTodo: {
|
|
4256
|
+
status: () => pmsTodo.status(),
|
|
4257
|
+
login: async (input) => await pmsTodo.login(input),
|
|
4258
|
+
logout: async () => await pmsTodo.logout(),
|
|
4259
|
+
today: async (signal) => await pmsTodo.today(signal),
|
|
4260
|
+
statistics: async (signal) => await pmsTodo.statistics(signal),
|
|
4261
|
+
setStatus: async (input) => {
|
|
4262
|
+
await pmsTodo.setStatus(input);
|
|
4263
|
+
},
|
|
4264
|
+
myTasks: async (signal) => await pmsTodo.myTasks(signal),
|
|
4265
|
+
completeTask: async (input) => {
|
|
4266
|
+
await pmsTodo.completeTask(input);
|
|
4267
|
+
},
|
|
4268
|
+
subscribe: (listener) => pmsTodo.subscribe(listener)
|
|
3891
4269
|
}
|
|
3892
4270
|
};
|
|
3893
4271
|
ctx.provide(HIWORK_FEATURE_CENTER_SERVICE, face);
|
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
|
+
}
|