hiwork-core 0.0.2 → 0.0.6

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.
@@ -1,4 +1,4 @@
1
- import type { LoginErrorCode } from './types.ts';
1
+ import type { DeviceSession, LoginErrorCode } from './types.ts';
2
2
  /** 网关错误:携带稳定 code,供编排层映射。 */
3
3
  export declare class GatewayError extends Error {
4
4
  readonly code: LoginErrorCode;
@@ -10,6 +10,16 @@ export interface GatewayUser {
10
10
  username: string;
11
11
  display_name?: string;
12
12
  }
13
+ /**
14
+ * 客户端设备身份:表单登录按 `X-HiWork-*` 头上报(网关 Task A8 落库到 sessions 表),
15
+ * 浏览器授权登录则作为授权 URL 的查询参数。`deviceId` 是唯一必需项,其余缺失即不发。
16
+ */
17
+ export interface DeviceIdentity {
18
+ deviceId: string;
19
+ deviceName?: string;
20
+ platform?: string;
21
+ appVersion?: string;
22
+ }
13
23
  /** GET /api/me/key 的 budgets 项(含额度字段,可选因网关可能缺省)。 */
14
24
  export interface GatewayBudget {
15
25
  model_name: string;
@@ -56,31 +66,273 @@ export interface GatewayKey {
56
66
  max_concurrency?: number;
57
67
  key_masked?: string;
58
68
  }
69
+ /**
70
+ * 用量统计的时间窗口。缺省(不传)时由网关按 `24h` 处理;`today`/`yesterday`/`mtd`
71
+ * 按自然边界切分(`mtd` = 本月 1 日 00:00 → 现在),网关自行决定聚合粒度
72
+ * (`group_by` 随响应返回)。
73
+ *
74
+ * 注意:`mtd` 需要网关侧支持(`meRange` 的 `case "mtd"`);旧网关不认该值时按缺省
75
+ * 回退成 `24h` 并在响应 `range` 里回填 `24h`——调用方必须用响应的 `range` 自证
76
+ * (见 `src/client/usage-data.ts` 的旧网关守卫),不得拿 24 小时的数字冒充本月。
77
+ */
78
+ export type GatewayUsageRange = 'today' | 'yesterday' | '24h' | '7d' | '30d' | 'mtd';
79
+ /** GET /api/me/analytics/summary 的按模型汇总项(网关 KeyModelSummaryStat)。 */
80
+ export interface GatewayModelSummary {
81
+ model: string;
82
+ request_count: number;
83
+ total_tokens: number;
84
+ total_cost: number;
85
+ cache_hit_rate: number;
86
+ }
87
+ /** GET /api/me/analytics/summary 响应。 */
88
+ export interface GatewaySummaryResponse {
89
+ /** 网关实际生效的时间窗口(请求未指定时网关回填)。 */
90
+ range: string;
91
+ items: GatewayModelSummary[];
92
+ }
93
+ /** 趋势点(GET /api/me/analytics/trends 的 trends 项)。 */
94
+ export interface GatewayTrendPoint {
95
+ /** 桶起点,RFC3339。 */
96
+ period: string;
97
+ prompt_tokens: number;
98
+ completion_tokens: number;
99
+ total_tokens: number;
100
+ request_count: number;
101
+ total_cost: number;
102
+ }
103
+ /** GET /api/me/analytics/trends 响应。 */
104
+ export interface GatewayTrendsResponse {
105
+ range: string;
106
+ /** 聚合粒度:`hour` | `day`(由网关按 range 决定)。 */
107
+ group_by: string;
108
+ trends: GatewayTrendPoint[];
109
+ }
110
+ /** 趋势点的子拆分(model-trends 按模型拆分时出现)。 */
111
+ export interface GatewayTrendBreakdown {
112
+ name: string;
113
+ total_tokens: number;
114
+ request_count: number;
115
+ total_cost: number;
116
+ }
117
+ /** 按模型拆分的趋势点:在趋势点基础上多一层 breakdown。 */
118
+ export interface GatewayModelTrendPoint extends GatewayTrendPoint {
119
+ breakdown?: GatewayTrendBreakdown[];
120
+ }
121
+ /** GET /api/me/analytics/model-trends 响应。 */
122
+ export interface GatewayModelTrendsResponse {
123
+ range: string;
124
+ group_by: string;
125
+ trends: GatewayModelTrendPoint[];
126
+ }
127
+ /** GET /api/me/key/recharges 的一项(网关 models.RechargeRecord 的 JSON 投影)。 */
128
+ export interface GatewayRecharge {
129
+ id: string;
130
+ virtual_key_id: string;
131
+ model_name: string;
132
+ /** 充值金额(元)。 */
133
+ amount: number;
134
+ /** 充值类型(网关自定义枚举,如 manual / auto_rule)。 */
135
+ type: string;
136
+ operator: string;
137
+ note: string;
138
+ /** 自动充值来源规则 id;非自动充值为 null。 */
139
+ rule_id?: string | null;
140
+ created_at: string;
141
+ }
142
+ /**
143
+ * POST /api/me/cost-estimate 的请求项。
144
+ * `input_tokens` 是**不含 cache** 的输入 token(与 DSH `uncachedInputTokens` 同口径),
145
+ * 折算成网关计费口径的 `prompt_tokens` 只在网关侧做——客户端不做任何算术。
146
+ */
147
+ export interface GatewayCostEstimateItem {
148
+ /** 网关协议名(openai_chat_completions / anthropic_messages / openai_responses,见 spec §6.2 词汇表)。 */
149
+ route: string;
150
+ /** 客户端请求的模型名(source_model)。 */
151
+ model: string;
152
+ /** 不含 cache 的输入 token。 */
153
+ input_tokens: number;
154
+ output_tokens: number;
155
+ /** 缺省视同 0。 */
156
+ cache_read_tokens?: number;
157
+ /** 缺省视同 0。 */
158
+ cache_write_tokens?: number;
159
+ /** 计价时刻(RFC3339);缺省由网关取 now。 */
160
+ at?: string;
161
+ }
162
+ /** 未计价原因(`priced: false` 时出现)。 */
163
+ export type GatewayCostEstimateReason = 'model_not_available' | 'no_channel' | 'price_not_found';
164
+ /** 本次实际生效的单价(网关只回这一条渠道价,不回价格表)。 */
165
+ export interface GatewayCostEstimatePrice {
166
+ input: number;
167
+ output: number;
168
+ cache: number;
169
+ cache_write: number;
170
+ }
171
+ /** POST /api/me/cost-estimate 的响应项。单项失败不影响整批(整体仍 200)。 */
172
+ export interface GatewayCostEstimateResult {
173
+ route: string;
174
+ model: string;
175
+ /** 未计价时为 0。 */
176
+ cost: number;
177
+ channel: string;
178
+ /** 候选渠道 ≥2 时按近 30 天流量加权,为 false(UI 需标注「估算」)。 */
179
+ channel_resolved: boolean;
180
+ price?: GatewayCostEstimatePrice;
181
+ priced_at?: string;
182
+ priced: boolean;
183
+ reason?: GatewayCostEstimateReason;
184
+ }
185
+ /** POST /api/me/cost-estimate 响应。 */
186
+ export interface GatewayCostEstimateResponse {
187
+ items: GatewayCostEstimateResult[];
188
+ total_cost: number;
189
+ }
190
+ /** GET /api/me/daily-quote 的条目(可选字段网关 omitempty;body 为弹窗正文段落)。 */
191
+ export interface GatewayDailyQuoteItem {
192
+ id: number;
193
+ content: string;
194
+ subtitle?: string;
195
+ title?: string;
196
+ source?: string;
197
+ body?: string[];
198
+ }
199
+ /**
200
+ * GET /api/me/daily-quote 响应:`date` 为东八区当日(YYYY-MM-DD,客户端弹窗直接展示),
201
+ * `poolSize` 为启用池大小;池空时 `item` 为 null。
202
+ */
203
+ export interface GatewayDailyQuoteResponse {
204
+ date: string;
205
+ poolSize: number;
206
+ item: GatewayDailyQuoteItem | null;
207
+ }
208
+ /** GET /api/me/daily-quote/random 响应:启用池随机条目(池空为 null)。 */
209
+ export interface GatewayDailyQuoteRandomResponse {
210
+ item: GatewayDailyQuoteItem | null;
211
+ }
59
212
  /** 网关客户端端口:orchestration 只依赖此接口。 */
60
213
  export interface GatewayClient {
61
- login(username: string, password: string): Promise<{
214
+ /**
215
+ * 表单登录(loginMode=form 的回退路径)。
216
+ * `device` 为可选设备信息:已知时随请求上报 `X-HiWork-*` 头(供网关记录登录设备),
217
+ * 未知则不发——**设备信息缺失绝不能让登录失败**。
218
+ */
219
+ login(username: string, password: string, device?: DeviceIdentity): Promise<{
220
+ sessionCookie: string;
221
+ user: GatewayUser;
222
+ }>;
223
+ /**
224
+ * 浏览器授权登录:用一次性 code + PKCE verifier 换取会话(返回形状与 login 一致)。
225
+ * 必选——所有网关客户端都必须实现,调用方无需判空。
226
+ */
227
+ exchangeCode(input: {
228
+ code: string;
229
+ codeVerifier: string;
230
+ deviceId: string;
231
+ }): Promise<{
62
232
  sessionCookie: string;
63
233
  user: GatewayUser;
64
234
  }>;
235
+ /** 当前账号的登录设备列表。 */
236
+ listSessions(sessionCookie: string): Promise<DeviceSession[]>;
237
+ /** 远程吊销指定会话(204 成功;404 表示该会话已不存在)。 */
238
+ revokeSession(sessionCookie: string, sessionId: string): Promise<void>;
65
239
  getMyKey(sessionCookie: string, keyId?: string): Promise<GatewayKeyResponse>;
66
240
  getMyKeys(sessionCookie: string): Promise<GatewayKey[]>;
241
+ /** 按模型聚合的用量汇总(权威账单口径)。 */
242
+ getMySummary(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewaySummaryResponse>;
243
+ /** token/费用走势(网关按 range 决定 group_by)。 */
244
+ getMyTrends(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewayTrendsResponse>;
245
+ /** 按模型拆分的走势(每点含 breakdown)。 */
246
+ getMyModelTrends(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewayModelTrendsResponse>;
247
+ /** 当前 Key 的充值台账(可按模型过滤)。 */
248
+ getMyKeyRecharges(sessionCookie: string, keyId?: string, modelName?: string): Promise<GatewayRecharge[]>;
249
+ /** 网关权威计价:只回金额与生效单价,客户端不做任何算式。 */
250
+ estimateCost(sessionCookie: string, items: GatewayCostEstimateItem[]): Promise<GatewayCostEstimateResponse>;
67
251
  logout(sessionCookie: string): Promise<void>;
68
252
  }
253
+ /**
254
+ * 构造浏览器授权页 URL(GET /api/auth/authorize)。
255
+ * 全部参数按 encodeURIComponent 转义——`redirect_uri` 的 `hiwork://` 必须编码成
256
+ * `hiwork%3A%2F%2Fauth%2Fcallback`,否则查询串会被网关按 `//` 截断。
257
+ */
258
+ export declare function buildAuthorizeURL(opts: {
259
+ baseURL: string;
260
+ clientId?: string;
261
+ redirectUri?: string;
262
+ codeChallenge: string;
263
+ state: string;
264
+ deviceId: string;
265
+ deviceName: string;
266
+ platform: string;
267
+ appVersion: string;
268
+ }): string;
69
269
  /** fetch + 解析 ai-router HTTP 面。零 dsh 依赖。 */
70
270
  export declare class AiRouterClient implements GatewayClient {
71
271
  private readonly baseURL;
72
272
  private readonly timeoutMs;
73
273
  constructor(baseURL: string, timeoutMs?: number);
74
- login(username: string, password: string): Promise<{
274
+ login(username: string, password: string, device?: DeviceIdentity): Promise<{
275
+ sessionCookie: string;
276
+ user: GatewayUser;
277
+ }>;
278
+ /**
279
+ * POST /api/auth/token:用一次性 code + PKCE verifier 换取会话。
280
+ * 返回 `session_id=<sid>`(与 login 同形状,编排层可直接复用),user 原样返回。
281
+ * 任何日志/异常信息都不得包含 code / code_verifier / session_id 明文。
282
+ */
283
+ exchangeCode(input: {
284
+ code: string;
285
+ codeVerifier: string;
286
+ deviceId: string;
287
+ }): Promise<{
75
288
  sessionCookie: string;
76
289
  user: GatewayUser;
77
290
  }>;
291
+ /** GET /api/auth/sessions:当前账号的登录设备列表。 */
292
+ listSessions(sessionCookie: string): Promise<DeviceSession[]>;
293
+ /** DELETE /api/auth/sessions/:id:远程吊销指定会话(204 成功;404 表示已不存在)。 */
294
+ revokeSession(sessionCookie: string, sessionId: string): Promise<void>;
78
295
  getMyKey(sessionCookie: string, keyId?: string): Promise<GatewayKeyResponse>;
79
296
  /** GET /api/me/keys:当前用户全部虚拟 Key 的轻量列表(多 Key 选择器数据源)。 */
80
297
  getMyKeys(sessionCookie: string): Promise<GatewayKey[]>;
81
298
  logout(sessionCookie: string): Promise<void>;
299
+ /** GET /api/me/analytics/summary:按模型聚合的用量汇总。 */
300
+ getMySummary(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewaySummaryResponse>;
301
+ /** GET /api/me/analytics/trends:token/费用走势。 */
302
+ getMyTrends(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewayTrendsResponse>;
303
+ /** GET /api/me/analytics/model-trends:按模型拆分的走势。 */
304
+ getMyModelTrends(sessionCookie: string, keyId?: string, range?: GatewayUsageRange): Promise<GatewayModelTrendsResponse>;
305
+ /** GET /api/me/key/recharges:当前 Key 的充值台账(可按模型过滤)。 */
306
+ getMyKeyRecharges(sessionCookie: string, keyId?: string, modelName?: string): Promise<GatewayRecharge[]>;
307
+ /** GET /api/me/daily-quote:当日条目(服务端东八区取模轮换,全网同一天同一条)。 */
308
+ getMyDailyQuote(sessionCookie: string): Promise<GatewayDailyQuoteResponse>;
309
+ /** GET /api/me/daily-quote/random:换一句(exclude 为当前条目 id;非法值由网关按缺省处理)。 */
310
+ getMyDailyQuoteRandom(sessionCookie: string, excludeId?: number): Promise<GatewayDailyQuoteRandomResponse>;
311
+ /**
312
+ * POST /api/me/cost-estimate:网关权威计价。请求体里的四个 token 桶原样上报,
313
+ * `input_tokens` 不含 cache;任何折算与单价计算都在网关侧完成。
314
+ */
315
+ estimateCost(sessionCookie: string, items: GatewayCostEstimateItem[]): Promise<GatewayCostEstimateResponse>;
316
+ /**
317
+ * /api/me 自助端点的 GET 公共路径:网络异常 → GATEWAY_UNREACHABLE,
318
+ * 401/403/400 映射为稳定 code,响应体非 JSON 时不把原文带进异常。
319
+ */
320
+ private getMeJson;
321
+ /**
322
+ * 浏览器授权相关端点专用:网络层异常(连接失败 / 超时 / DNS)统一映射为
323
+ * GATEWAY_UNREACHABLE,避免裸 fetch 错误漏到编排层。
324
+ * login 等既有方法不经过此处,行为保持不变。
325
+ */
326
+ private requestOrUnreachable;
82
327
  private request;
83
328
  }
329
+ /**
330
+ * 设备信息 → 请求头。四个字段一律先清洗:
331
+ * `deviceId` 不可信(见 {@link safeDeviceId})时一个头都不发;其余字段经
332
+ * {@link headerSafeValue} 清洗后为空则该头跳过、其余照发。
333
+ * 任何异常输入都不得让登录失败——本函数总是返回可安全交给 `Headers` 的对象。
334
+ */
335
+ export declare function deviceHeaders(device?: DeviceIdentity): Record<string, string>;
84
336
  /**
85
337
  * 从 Set-Cookie 头解析 session_id:遍历全部 Set-Cookie(网关可能先下发 CSRF 等其他 cookie);
86
338
  * 旧运行时回退 get('set-cookie') 时可能拿到逗号合并串,故分隔符同时接受 `,` 与 `;`。
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 浏览器授权登录(`hiwork://` + PKCE)的宿主侧编排:发起与回调兑换。
3
+ *
4
+ * 接缝(与 pendingLogin / refreshRequest 同一机制,**不引入新的 RPC 通道**):
5
+ * - 客户端写 `browserLoginRequest: { at }` 请求发起浏览器登录;
6
+ * - 宿主发起后把授权 URL 回写为 `browserLogin: { authorizeUrl, startedAt }` 并清掉
7
+ * `browserLoginRequest`,由渲染端(client 半边)负责 `window.open(authorizeUrl)`;
8
+ * - 桌面端收到 `hiwork://auth/callback` 后经 IPC 调 `completeBrowserLogin({ code, state })`。
9
+ *
10
+ * 安全约束(见 spec §5.2):PKCE S256 是核心防线,state 严格相等才兑换,code 一次性,
11
+ * 任何日志都不得输出 code / code_verifier / code_challenge / session_id 明文。
12
+ */
13
+ import { type DeviceIdentity, type GatewayClient } from './ai-router-client.ts';
14
+ import { type AiRouterNamespaceValue, type AiRouterStatePort, type CredentialsPort, type DefaultModelPort, type PiAiPort } from './provision-session.ts';
15
+ import type { LoginError } from './types.ts';
16
+ /** 待完成授权请求的有效期:超过 10 分钟的回调一律拒绝(授权页/一次性 code 早已失效)。 */
17
+ export declare const BROWSER_LOGIN_TTL_MS: number;
18
+ /** 设备 id 在 credentials 里的兜底 ref(桌面端 Task C3 未推来时的本地持久化)。 */
19
+ export declare const DEVICE_ID_REF = "hiwork_device_id";
20
+ /** 缺 baseURL 时写进 lastError 的稳定错误(发起与分发两侧共用同一文案)。 */
21
+ export declare const NO_BASE_URL_ERROR: LoginError;
22
+ /** 发起浏览器登录所需的最小端口。 */
23
+ export interface BrowserLoginStartPorts {
24
+ credentials: CredentialsPort;
25
+ state: AiRouterStatePort;
26
+ value: AiRouterNamespaceValue;
27
+ /** 时钟注入(测试用);缺省 Date.now。 */
28
+ now?: () => number;
29
+ /** 告警出口;缺省静默。 */
30
+ log?: (message: string) => void;
31
+ }
32
+ /** 浏览器登录编排的公共端口(发起与回调共用;测试可注入时钟与告警出口)。 */
33
+ export interface BrowserLoginPorts extends BrowserLoginStartPorts {
34
+ piAi: PiAiPort;
35
+ /** 供给成功后校正默认模型;可选以便测试直接构造 opts。 */
36
+ defaultModel?: DefaultModelPort;
37
+ /** 供给成功后触发(默认工作区 ensure 等);可选以便测试直接构造 opts。 */
38
+ ensureWorkspace?: () => Promise<void>;
39
+ }
40
+ /** 发起成功的返回值:授权页 URL 与本次 state(state 仅用于回调校验,不得写日志)。 */
41
+ export interface StartBrowserLoginResult {
42
+ authorizeUrl: string;
43
+ state: string;
44
+ }
45
+ /**
46
+ * 接收桌面端经 IPC 推来的设备信息(Task B3 的 `hiwork:device-info` 调用)。
47
+ * deviceId 为空/纯空白视为无效并忽略(保留既有缓存);deviceName / appVersion 可选。
48
+ * `appVersion` 是**桌面端版本**(网关把它记进会话并在授权页展示),缺失时才回退到插件版本。
49
+ */
50
+ export declare function setDeviceInfo(info: {
51
+ deviceId: string;
52
+ deviceName?: string;
53
+ appVersion?: string;
54
+ }): void;
55
+ /** 清空模块级状态(IPC 设备缓存 + 待完成授权);供测试与插件卸载使用。 */
56
+ export declare function resetBrowserLoginState(): void;
57
+ /**
58
+ * 解析本次登录要上报的设备身份:IPC 推来的值 → credentials 里的 `hiwork_device_id`
59
+ * → 都没有则生成 UUID 并持久化。**永远返回非空 deviceId**,调用方不必判空。
60
+ */
61
+ export declare function resolveDeviceInfo(credentials: CredentialsPort): Promise<DeviceIdentity>;
62
+ /**
63
+ * PKCE S256:`base64url(sha256(verifier))`(RFC 7636 §4.2)。导出以便单测用官方测试向量核对。
64
+ */
65
+ export declare function pkceChallenge(codeVerifier: string): string;
66
+ /**
67
+ * 发起浏览器授权登录:生成 PKCE 与 state、暂存 pendingAuth、拼授权 URL 并回写命名空间。
68
+ * @returns 授权 URL 与 state;缺 baseURL 时写 lastError 并返回 undefined。
69
+ */
70
+ export declare function startBrowserLogin(opts: BrowserLoginStartPorts): Promise<StartBrowserLoginResult | undefined>;
71
+ /**
72
+ * 宿主启动时对账持久化的等待态。
73
+ *
74
+ * PKCE 的 code_verifier 只活在内存的 `pendingAuth` 里,**不落任何持久化**;而等待态
75
+ * `browserLogin` 是持久化的。宿主一旦重启(应用重开、DSH 崩溃重启、开发期 HMR),
76
+ * 两者就永久错位:所有回调都被 `completeBrowserLogin` 的「没有待完成的授权请求」丢弃,
77
+ * 界面却仍按持久化的 `browserLogin` 停在「等待完成授权」,用户只能靠「取消」脱困。
78
+ *
79
+ * 因此启动即把这种**不可能完成**的等待态收尾成失败:写一条 lastError 并清掉等待字段,
80
+ * 让用户回到登录入口重新发起。没有等待态时不动任何状态。
81
+ */
82
+ export declare function reconcileStaleBrowserLogin(state: AiRouterStatePort, value: Pick<AiRouterNamespaceValue, 'browserLogin'>): Promise<void>;
83
+ /**
84
+ * 处理 `hiwork://auth/callback`:校验 state → 兑换一次性 code → 写会话凭据 → 复用供给尾部。
85
+ *
86
+ * 一次性语义:state **已匹配**的路径(超时 / error / 缺 code / 兑换失败 / 成功)一律在
87
+ * finally 里清空 pendingAuth,重复回调只生效一次。
88
+ *
89
+ * 安全例外:state 不匹配的回调是**纯丢弃**——它可能来自攻击者伪造,因此不得写状态、不得
90
+ * 清 pendingAuth、不得触碰凭据(见下方早返回,该路径刻意绕过 finally)。否则一次伪造回调
91
+ * 就能让随后真实的回调被当成「无待完成授权」而忽略,等于无凭据即可打断用户登录。
92
+ */
93
+ export declare function completeBrowserLogin(input: {
94
+ code?: string;
95
+ state?: string;
96
+ error?: string;
97
+ }, opts: BrowserLoginPorts & {
98
+ gateway: GatewayClient;
99
+ }): Promise<LoginError | null>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 账号 / Key 身份变化 → 清空用量类缓存。
3
+ *
4
+ * 为什么需要它:`UsageDataStore` 与 `SessionCostStore` 的缓存键分别只含
5
+ * `keyId+range` 与 `sessionId+turn+items`,**不含账号**。同一台机器上换账号(或换 Key)
6
+ * 后查看**同一个本地会话**,5 分钟 TTL 内会命中上一账号的估算金额——网关的可用模型与
7
+ * 单价是按 Key 解析的,串号就是错钱。
8
+ *
9
+ * 身份来源是 ai-router 设置卡片的状态快照(`controller.inject().hooks.aiRouterCard`):
10
+ * - 登出:Host 的 `runLogout` 会 unset `username` / `keyId`(`src/orchestration.ts`),
11
+ * 身份摘要随之变化 → 清空;
12
+ * - 换账号 / 换 Key:`username` 或 `activeKeyId` 变化 → 清空。
13
+ *
14
+ * 本模块不 import 任何 `@deepseek-ai/*` 运行时值,只按结构声明所需的最小面,
15
+ * 便于单测直接驱动。
16
+ */
17
+ /** 身份摘要的输入(`AiRouterCardState` 的子集)。 */
18
+ export interface AccountIdentityState {
19
+ username?: string | undefined;
20
+ /** 当前生效 Key(显式 keyId,缺省取 keys 第一项)。 */
21
+ activeKeyId?: string | undefined;
22
+ }
23
+ /** 快照源(结构兼容 `SnapshotStore`,只用到读与订阅)。 */
24
+ export interface AccountIdentitySource {
25
+ getSnapshot(): AccountIdentityState;
26
+ subscribe(listener: () => void): () => void;
27
+ }
28
+ /**
29
+ * 身份摘要:账号 + 当前 Key。两者任一变化都意味着「金额的解释口径变了」。
30
+ * 未登录时两者都是空串——登出(从有值变空)同样会被判为变化。
31
+ * @param state - 卡片状态子集。
32
+ * @returns 稳定摘要串。
33
+ */
34
+ export declare function accountIdentityOf(state: AccountIdentityState): string;
35
+ /**
36
+ * 订阅身份变化;变化时调一次 `onChange`(不传新值——调用方只需清缓存)。
37
+ *
38
+ * 语义:
39
+ * - 只比较 {@link accountIdentityOf},同一身份重复通知不触发;
40
+ * - 立即读取一次当前身份作为基线(订阅前已登录的账号不会被当成「变化」);
41
+ * - 返回幂等的取消订阅函数(`ctx.effect` 的清理面)。
42
+ * @param source - 卡片状态快照源。
43
+ * @param onChange - 身份变化回调(清空缓存)。
44
+ * @returns 取消订阅。
45
+ */
46
+ export declare function observeAccountChange(source: AccountIdentitySource, onChange: () => void): () => void;
@@ -15,6 +15,13 @@ export declare const AI_ROUTER_NS = "ai-router";
15
15
  export declare const AI_ROUTER_SECTION_LABEL = "\u7F51\u5173\u6A21\u578B";
16
16
  /** 登录中临时密码的凭据 ref:与 src/provision.ts 拼写一致。 */
17
17
  export declare const PENDING_PASSWORD_REF = "AI_ROUTER_PENDING_PASSWORD";
18
+ /**
19
+ * 登录方式:由 Host 从 `GET /api/client/policy` 投影(见 spec §5.4)。
20
+ * - `browser`:浏览器授权登录为主,表单只作为故障回退;
21
+ * - `both`:浏览器授权为主 + 折叠的账号密码表单(缺省值);
22
+ * - `form`:完全回退到表单登录(浏览器流故障时的紧急止血)。
23
+ */
24
+ export type LoginMode = 'browser' | 'form' | 'both';
18
25
  /** 卡片编辑的 ai-router 配置段子集。 */
19
26
  export interface AiRouterCardSettings {
20
27
  baseURL?: string;
@@ -40,6 +47,17 @@ export interface AiRouterCardSettings {
40
47
  lastError?: LoginError;
41
48
  budgets?: GatewayBudgetView[];
42
49
  keys?: GatewayKeyView[];
50
+ /** 网关策略下发的登录方式;缺省或非法值一律按 `both`。 */
51
+ loginMode?: LoginMode;
52
+ /** 客户端写:请求 Host 发起浏览器授权登录(Host 完成后清掉本字段)。 */
53
+ browserLoginRequest?: {
54
+ at: string;
55
+ };
56
+ /** Host 回写:待打开的授权页 URL 与发起时间(渲染层据此 `window.open`)。 */
57
+ browserLogin?: {
58
+ authorizeUrl: string;
59
+ startedAt: string;
60
+ };
43
61
  }
44
62
  /** 单个模型的额度视图(client 侧结构类型,不 import Host)。 */
45
63
  export interface GatewayBudgetView {
@@ -71,6 +89,12 @@ export interface AiRouterCardState {
71
89
  keys: GatewayKeyView[] | undefined;
72
90
  /** 当前生效 Key:显式 keyId,缺省取 keys 第一项(与网关默认一致)。 */
73
91
  activeKeyId: string | undefined;
92
+ /** 登录方式(投影后永不为 undefined;缺省/非法值归一为 both)。 */
93
+ loginMode: LoginMode;
94
+ /** 授权页 URL:有值即等待浏览器授权中。 */
95
+ browserLoginUrl: string | undefined;
96
+ /** 是否等待浏览器授权(含请求已发出、URL 未回写的窗口期)。 */
97
+ browserLoginPending: boolean;
74
98
  }
75
99
  /** 卡片动作与快照(slot 注入面)。 */
76
100
  export interface AiRouterCardFace {
@@ -82,6 +106,12 @@ export interface AiRouterCardFace {
82
106
  refresh(): Promise<void>;
83
107
  logout(): Promise<void>;
84
108
  selectKey(keyId: string): Promise<void>;
109
+ /** 发起浏览器授权登录(写 browserLoginRequest 请求字段)。 */
110
+ startBrowserLogin(): Promise<void>;
111
+ /** 取消浏览器授权登录等待态(清掉请求字段与回写的授权 URL)。 */
112
+ cancelBrowserLogin(): Promise<void>;
113
+ /** 重新打开当前授权页;无 URL 时 no-op。 */
114
+ reopenBrowserLogin(): void;
85
115
  };
86
116
  }
87
117
  /**
@@ -90,6 +120,40 @@ export interface AiRouterCardFace {
90
120
  * 实现方抛错,控制器把失败交给登录弹窗展示。
91
121
  */
92
122
  export type CredentialStorePort = (ref: string, value: string) => Promise<void>;
123
+ /**
124
+ * 归一化登录方式:只有明确的 `browser` / `form` 才生效,其余(缺省、旧 Host 未下发、
125
+ * 非法值)一律按 `both`——fail-open,宁可多给一个入口,也不把用户锁在门外(spec §5.4)。
126
+ */
127
+ export declare function normalizeLoginMode(value: unknown): LoginMode;
128
+ /**
129
+ * Host 回写的授权页 URL:只有非空 http(s) 才算就绪。命名空间值可能被任意写入,
130
+ * 因此这里就把非 http(s)(`javascript:` 等)挡掉,投影与打开两处口径一致。
131
+ */
132
+ export declare function authorizeUrlOf(value: AiRouterCardSettings | undefined): string | undefined;
133
+ /**
134
+ * 是否处于浏览器登录等待态:授权 URL 已回写,或请求已发出但 Host 尚未回写。
135
+ * 后一种窗口期也算等待,避免用户重复点击发起第二次授权。
136
+ */
137
+ export declare function browserLoginPendingOf(value: AiRouterCardSettings | undefined): boolean;
138
+ /**
139
+ * 浏览器登录的投影视图(登录门禁与卡片共用):`form` 模式下一律不暴露浏览器登录
140
+ * 入口——连残留的授权 URL 也一并抹掉,避免渲染层对旧 URL 再 `window.open` 一次。
141
+ */
142
+ export declare function browserLoginViewOf(value: AiRouterCardSettings | undefined): {
143
+ loginMode: LoginMode;
144
+ browserLoginUrl: string | undefined;
145
+ browserLoginPending: boolean;
146
+ };
147
+ /**
148
+ * 打开授权页(渲染层职责):桌面端 `shell.openExternal` 路由接管 https 外链,桌面端无需改动。
149
+ * 命名空间值可被任意写入,因此只放行 http(s),其余静默忽略。
150
+ * @param url - Host 回写的授权页 URL;缺省 / 非法时为 no-op。
151
+ */
152
+ export declare function openAuthorizeUrl(url: string | undefined): void;
153
+ /** 请求 Host 发起浏览器授权登录(Host 生成 PKCE 后回写 `browserLogin`)。 */
154
+ export declare function requestBrowserLogin(scope: SettingsScope<AiRouterCardSettings>): Promise<void>;
155
+ /** 取消 / 结束浏览器登录等待态:清掉请求字段与 Host 回写的授权 URL。 */
156
+ export declare function clearBrowserLogin(scope: SettingsScope<AiRouterCardSettings>): Promise<void>;
93
157
  /** 桥接 `ai-router` 作用域到卡片状态与动作。 */
94
158
  export declare class AiRouterCardController {
95
159
  private readonly scope;
@@ -102,4 +166,8 @@ export declare class AiRouterCardController {
102
166
  private selectKey;
103
167
  private refresh;
104
168
  private logout;
169
+ private startBrowserLogin;
170
+ private cancelBrowserLogin;
171
+ /** 重新打开授权页:URL 取自最近一次投影,缺省 no-op(登录门禁另有自动打开)。 */
172
+ private reopenBrowserLogin;
105
173
  }
@@ -1,16 +1,24 @@
1
1
  /**
2
- * 新建任务空白会话 hero 页的「每日卡片 v2 · 置顶精简版」(自 hiwork-dsh-ui 迁移):
3
- * 把《海普发展纲领》当日条目以「灰小字前缀 + 蓝渐变大字主体」一行展示在 hero 标题
4
- * (headline)上方,点击后弹出一次性模态窗展示 归属链/日期/整句标题/逐字原文段落。
5
- * 主行下方带一枚「换一句」文字按钮:临时预览排除当前条目的随机条目(不改 dataset.day,
6
- * 故 rAF 观察循环不会重置它),仅当日历日变更时按既有逻辑重建回当日条目。
7
- * 只插入插件自有(dcu 标记)节点:不搬移/不改写任何宿主节点;宿主重建 hero 时自动重挂;
8
- * 非 hero / 槽位缺失时自动清残留(含弹窗)。
2
+ * 新建任务空白会话 hero 页的「每日一句」卡片(服务端下发):
3
+ * 把网关当日条目以「灰小字前缀 + 蓝渐变大字主体」一行展示在 hero 标题(headline)
4
+ * 上方,点击后弹出一次性模态窗展示 出处/日期/标题/正文段落 + 「知道了」。
5
+ * 主行下方带一枚「换一句」文字按钮:临时预览排除当前条目的随机条目(不改
6
+ * dataset.date,故 rAF 观察循环不会重置它),仅当日历日变更时按既有逻辑重建回当日条目。
7
+ *
8
+ * 数据经 {@link DailyQuoteSource}(`/hiwork-daily` loopback RPC + 全天缓存)异步获取:
9
+ * 数据未就绪或当日无可展示内容(池空 / RPC 失败)的帧不插卡片;hero 页是装饰位,
10
+ * 任何失败都不出错误 UI、不打断主流程。问候语轮换(decorateHeroGreeting)不受影响、
11
+ * 照常每帧执行。
9
12
  *
10
13
  * dcu 标记与 hiwork-dsh-ui 保持同名:两插件同环境共存时互认对方已挂的卡片,
11
14
  * decorateDailyCard 的幂等检查只会让先挂者生效,不会双挂载。
12
15
  */
13
- import { type DailyItem } from './daily-items.ts';
16
+ import { type DailyQuoteView, type DailyTodayView } from './daily-data.ts';
17
+ /** 每日一句取数面(结构兼容 daily-data 的 DailyDataStore,便于测试伪造)。 */
18
+ export interface DailyQuoteSource {
19
+ getToday(): Promise<DailyTodayView | null>;
20
+ getRandom(excludeId?: number): Promise<DailyQuoteView | null>;
21
+ }
14
22
  export declare const DAILY_CARD_STYLE_ID = "dcu-daily-card-style";
15
23
  export declare const DAILY_CARD_STYLE = "\n[data-dcu-daily-card]{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;width:100%;max-width:min(880px,100%);margin:6px auto 44px;cursor:pointer;user-select:none;text-align:center}\n[data-dcu-daily-prefix]{display:inline-flex;align-items:center;gap:14px;font-size:20px;line-height:1.4;font-weight:500;letter-spacing:.22em;text-indent:.22em;color:rgba(24,24,27,.35);white-space:nowrap}\n[data-dcu-daily-prefix]::before,[data-dcu-daily-prefix]::after{content:\"\";display:inline-block;width:44px;height:2px;border-radius:1px;background:currentColor;opacity:.5}\n[data-dcu-daily-prefix][hidden]{display:none}\n[data-dcu-daily-main]{margin-top:6px;font-size:64px;line-height:1.25;font-weight:650;letter-spacing:.005em;background:linear-gradient(100deg,#2f6bf0,#1d4ed8 45%,#0e7fc2);-webkit-background-clip:text;background-clip:text;color:transparent;transition:filter .18s ease}\n[data-dcu-daily-card]:hover [data-dcu-daily-main]{filter:brightness(1.12)}\n[data-dcu-daily-card]:focus-visible{outline:2px solid var(--dsw-alias-button-info-fill,#4c8dff);outline-offset:4px;border-radius:6px}\n[data-dcu-daily-shuffle]{flex:none;display:inline-flex;align-items:center;justify-content:center;gap:5px;margin-top:10px;padding:3px 10px;border-radius:8px;color:var(--dsw-alias-label-tertiary,#71717a);font-size:13px;line-height:20px;letter-spacing:.08em;text-indent:.08em;cursor:pointer;transition:color .18s ease}\n[data-dcu-daily-shuffle] svg{flex:none;width:13px;height:13px;transition:transform .45s ease}\n[data-dcu-daily-shuffle]:hover svg{transform:rotate(360deg)}\n[data-dcu-daily-shuffle]:hover{color:var(--dsw-alias-label-secondary,#3f3f46)}\n[data-dcu-daily-shuffle]:focus-visible{outline:2px solid var(--dsw-alias-button-info-fill,#4c8dff);outline-offset:2px}\nbody[data-ds-dark-theme] [data-dcu-daily-prefix]{color:rgba(242,244,248,.38)}\nbody[data-ds-dark-theme] [data-dcu-daily-main]{background:linear-gradient(100deg,#6b9bff,#3d7bff 45%,#4cc3ff);-webkit-background-clip:text;background-clip:text;color:transparent}\nbody[data-ds-dark-theme] [data-dcu-daily-shuffle]{color:var(--dsw-alias-label-tertiary,#9ba3b4)}\nbody[data-ds-dark-theme] [data-dcu-daily-shuffle]:hover{color:var(--dsw-alias-label-secondary,#a6adb8)}\n[data-dcu-daily-modal]{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(15,17,23,.55)}\n[data-dcu-daily-modal-box]{box-sizing:border-box;width:min(620px,100%);max-height:88vh;overflow:auto;border-radius:18px;border:1px solid var(--dsw-alias-border-subtle,rgba(24,24,27,.12));background:var(--dsw-alias-background-primary,#ffffff);box-shadow:0 24px 64px rgba(0,0,0,.28);padding:24px 28px;color:var(--dsw-alias-label-primary,#18181b)}\n[data-dcu-daily-modal-meta]{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--dsw-alias-label-tertiary,#71717a);font-size:12.5px;line-height:18px;letter-spacing:.02em}\n[data-dcu-daily-modal-title]{margin-top:14px;font-size:24px;line-height:1.45;font-weight:650;color:var(--dsw-alias-label-primary,#18181b)}\n[data-dcu-daily-modal-body]{margin-top:16px;color:var(--dsw-alias-label-secondary,#3f3f46);font-size:14.5px;line-height:1.95}\n[data-dcu-daily-modal-body] > p{margin:0 0 10px}\n[data-dcu-daily-modal-body] > p:last-child{margin-bottom:0}\n[data-dcu-daily-modal-close]{appearance:none;margin-top:20px;padding:7px 18px;border:1px solid var(--dsw-alias-border-strong,rgba(24,24,27,.2));border-radius:9px;background:var(--dsw-alias-button-secondary-fill,transparent);color:var(--dsw-alias-label-secondary,#3f3f46);font-size:13px;line-height:20px;cursor:pointer}\n[data-dcu-daily-modal-close]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(24,24,27,.06))}\n[data-dcu-daily-modal-close]:focus-visible{outline:2px solid var(--dsw-alias-button-info-fill,#4c8dff);outline-offset:2px}\nbody[data-ds-dark-theme] [data-dcu-daily-modal]{background:rgba(0,0,0,.62)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-box]{border-color:var(--dsw-alias-border-subtle,rgba(255,255,255,.12));background:var(--dsw-alias-background-primary,#10131b);color:var(--dsw-alias-label-primary,#f2f4f8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-meta]{color:var(--dsw-alias-label-tertiary,#9ba3b4)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-title]{color:var(--dsw-alias-label-primary,#f2f4f8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-body]{color:var(--dsw-alias-label-secondary,#a6adb8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-close]{border-color:var(--dsw-alias-border-subtle,rgba(255,255,255,.16));color:var(--dsw-alias-label-secondary,#a6adb8)}\nbody[data-ds-dark-theme] [data-dcu-daily-modal-close]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08))}\n@media (prefers-reduced-motion: reduce){[data-dcu-daily-card] [data-dcu-daily-main],[data-dcu-daily-shuffle],[data-dcu-daily-shuffle] svg,[data-dcu-daily-modal-box]{transition:none}}\n";
16
24
  export declare function ensureDailyCardStyle(doc: Document): void;
@@ -20,20 +28,21 @@ export declare function ensureDailyCardStyle(doc: Document): void;
20
28
  */
21
29
  export declare function findDailyCardMount(doc: Document): HTMLElement | null;
22
30
  /**
23
- * 「随机换一条」抽取:从 75 条池中随机取一条,命中 exclude 时按池长模长顺移重掷,
24
- * 保证永不等于当前展示条目(75 条池必有另一条,循环至多绕一圈即终止)。
25
- */
26
- export declare function pickRandomItem(exclude: DailyItem): DailyItem;
27
- /**
28
- * 幂等装饰:hero + headline 缺失 → 只清残留;已有时当日卡片紧邻同一条 headline → 不动;
29
- * 否则清掉旧卡片后把当日主行作为 headline 的前一个兄弟插入(不搬移任何宿主节点)。
31
+ * 幂等装饰:hero + headline 缺失 → 只清残留;否则取当日缓存数据异步落位——
32
+ * - 数据未就绪(悬而未决)→ 本帧不动,等数据就绪的微任务再插;
33
+ * - 当日无可展示内容(null:池空 / RPC 失败)→ 清掉残留卡片(如昨日内容),不插新卡;
34
+ * - 已有卡片且 dataset.date 与缓存 date 一致 → 不动(「换一句」的随机预览因此不被重置);
35
+ * - 其余 → 清掉旧卡片后把当日主行作为 headline 的前一个兄弟插入(不搬移任何宿主节点)。
36
+ *
37
+ * 落位前重新校验 hero / headline 仍在场(异步间隙宿主可能已切页或重建 DOM)。
30
38
  */
31
- export declare function decorateDailyCard(doc: Document): void;
39
+ export declare function decorateDailyCard(doc: Document, quotes: DailyQuoteSource): void;
32
40
  /** 清理非 hero / 槽位变化后残留的卡片节点,以及任何仍在打开的弹窗。 */
33
41
  export declare function removeStaleDailyCard(doc: Document): void;
34
42
  /**
35
- * 观察新建任务 hero 页并装饰同页组件:每日卡片 + hero 问候语轮换(见 hero-greeting.ts)。
36
- * DOM 变更按帧合并,避免流式输出时每个 token 都全文档扫描;页面完全空闲跨午夜时
37
- * MutationObserver 不会触发,靠周期轮询兜底换日。返回卸载清理函数。
43
+ * 观察新建任务 hero 页并装饰同页组件:每日一句(服务端下发)+ hero 问候语轮换
44
+ * (见 hero-greeting.ts)。DOM 变更按帧合并,避免流式输出时每个 token 都全文档扫描;
45
+ * 页面完全空闲跨午夜时 MutationObserver 不会触发,靠周期轮询兜底换日(本地日变更
46
+ * 触发重新拉取,服务端日期随之更新)。返回卸载清理函数。
38
47
  */
39
- export declare function observeHeroDecorations(doc?: Document): () => void;
48
+ export declare function observeHeroDecorations(doc: Document, quotes: DailyQuoteSource): () => void;