@awiki/dsh-plugin 0.2.0-rc.4
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/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +114 -0
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +8398 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1497 -0
- package/lib/invariant.js +16 -0
- package/lib/provider.js +22 -0
- package/lib/sdk-adapter-DPPZhs6p.mjs +316 -0
- package/lib/summary-provider.js +192 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1201 -0
- package/lib/typert.remote-client.d.ts +54 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +817 -0
- package/lib/types/client/AwikiOverlay.d.ts +22 -0
- package/lib/types/client/AwikiOverlay.d.ts.map +1 -0
- package/lib/types/client/AwikiOverlay.js +689 -0
- package/lib/types/client/AwikiOverlay.js.map +1 -0
- package/lib/types/client/AwikiSettingsSection.d.ts +25 -0
- package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -0
- package/lib/types/client/AwikiSettingsSection.js +119 -0
- package/lib/types/client/AwikiSettingsSection.js.map +1 -0
- package/lib/types/client/assets.d.ts +3 -0
- package/lib/types/client/assets.d.ts.map +1 -0
- package/lib/types/client/assets.js +70 -0
- package/lib/types/client/assets.js.map +1 -0
- package/lib/types/client/controller.d.ts +194 -0
- package/lib/types/client/controller.d.ts.map +1 -0
- package/lib/types/client/controller.js +701 -0
- package/lib/types/client/controller.js.map +1 -0
- package/lib/types/client/file.d.ts +20 -0
- package/lib/types/client/file.d.ts.map +1 -0
- package/lib/types/client/file.js +41 -0
- package/lib/types/client/file.js.map +1 -0
- package/lib/types/client/index.d.ts +16 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +129 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/settings-controller.d.ts +28 -0
- package/lib/types/client/settings-controller.d.ts.map +1 -0
- package/lib/types/client/settings-controller.js +139 -0
- package/lib/types/client/settings-controller.js.map +1 -0
- package/lib/types/client/settings-locales.d.ts +13 -0
- package/lib/types/client/settings-locales.d.ts.map +1 -0
- package/lib/types/client/settings-locales.js +68 -0
- package/lib/types/client/settings-locales.js.map +1 -0
- package/lib/types/client/slots.d.ts +74 -0
- package/lib/types/client/slots.d.ts.map +1 -0
- package/lib/types/client/slots.js +3 -0
- package/lib/types/client/slots.js.map +1 -0
- package/lib/types/client/store.d.ts +18 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/client/store.js +17 -0
- package/lib/types/client/store.js.map +1 -0
- package/lib/types/client.d.ts +3 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/client.js +3 -0
- package/lib/types/client.js.map +1 -0
- package/lib/types/domain.d.ts +10 -0
- package/lib/types/domain.d.ts.map +1 -0
- package/lib/types/domain.js +18 -0
- package/lib/types/domain.js.map +1 -0
- package/lib/types/index.d.ts +188 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +861 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +14 -0
- package/lib/types/invariant.js.map +1 -0
- package/lib/types/provider-api.d.ts +70 -0
- package/lib/types/provider-api.d.ts.map +1 -0
- package/lib/types/provider-api.js +3 -0
- package/lib/types/provider-api.js.map +1 -0
- package/lib/types/provider.d.ts +9 -0
- package/lib/types/provider.d.ts.map +1 -0
- package/lib/types/provider.js +20 -0
- package/lib/types/provider.js.map +1 -0
- package/lib/types/sdk-adapter.d.ts +46 -0
- package/lib/types/sdk-adapter.d.ts.map +1 -0
- package/lib/types/sdk-adapter.js +327 -0
- package/lib/types/sdk-adapter.js.map +1 -0
- package/lib/types/session.d.ts +14 -0
- package/lib/types/session.d.ts.map +1 -0
- package/lib/types/session.js +76 -0
- package/lib/types/session.js.map +1 -0
- package/lib/types/settings-rpc-contract.d.ts +34 -0
- package/lib/types/settings-rpc-contract.d.ts.map +1 -0
- package/lib/types/settings-rpc-contract.js +62 -0
- package/lib/types/settings-rpc-contract.js.map +1 -0
- package/lib/types/settings-rpc.d.ts +6 -0
- package/lib/types/settings-rpc.d.ts.map +1 -0
- package/lib/types/settings-rpc.js +147 -0
- package/lib/types/settings-rpc.js.map +1 -0
- package/lib/types/settings.d.ts +13 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/settings.js +15 -0
- package/lib/types/settings.js.map +1 -0
- package/lib/types/summary-provider-api.d.ts +45 -0
- package/lib/types/summary-provider-api.d.ts.map +1 -0
- package/lib/types/summary-provider-api.js +3 -0
- package/lib/types/summary-provider-api.js.map +1 -0
- package/lib/types/summary-provider.d.ts +48 -0
- package/lib/types/summary-provider.d.ts.map +1 -0
- package/lib/types/summary-provider.js +208 -0
- package/lib/types/summary-provider.js.map +1 -0
- package/lib/types/tools.d.ts +20 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/tools.js +139 -0
- package/lib/types/tools.js.map +1 -0
- package/lib/types/types.d.ts +292 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +6 -0
- package/lib/types/types.js.map +1 -0
- package/package.json +179 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/** React-free browser controller for the deployment's one AWiki identity. */
|
|
2
|
+
/** Turn a registration rejection into an actionable message without exposing remote response text. */
|
|
3
|
+
function registrationFailureMessage(failure) {
|
|
4
|
+
switch (failure.code) {
|
|
5
|
+
case 'already-registered':
|
|
6
|
+
return '当前设备已注册 AWiki 身份,请刷新后继续使用。';
|
|
7
|
+
case 'invalid-request':
|
|
8
|
+
return '注册信息不匹配,请检查手机号、Handle 和验证码后重试。';
|
|
9
|
+
case 'invalid-otp':
|
|
10
|
+
return '验证码不正确,请检查后重试。';
|
|
11
|
+
case 'challenge-expired':
|
|
12
|
+
return '验证码状态已失效,请重新获取验证码后再注册。';
|
|
13
|
+
case 'handle-unavailable':
|
|
14
|
+
return '该 Handle 已存在,无法重复注册。请更换一个未使用的 Handle,并重新获取验证码。';
|
|
15
|
+
case 'conflict':
|
|
16
|
+
return '注册冲突:服务端可能已收到上次注册请求,或该手机号 / Handle 已绑定其他身份。请保留当前页面并再次提交;若仍失败,请勿清除本机身份数据,联系管理员并提供失败时间。';
|
|
17
|
+
case 'rate-limited':
|
|
18
|
+
return '注册请求过于频繁,请稍后重试。';
|
|
19
|
+
case 'network':
|
|
20
|
+
return '无法连接 AWiki 服务,请检查网络后重试。';
|
|
21
|
+
case 'forbidden':
|
|
22
|
+
return '当前 AWiki 服务未开放公开注册,或该手机号不在注册白名单。请使用已获准的手机号,或联系管理员开通注册权限。';
|
|
23
|
+
case 'remote':
|
|
24
|
+
return 'AWiki 服务暂时无法完成注册,请稍后重试;若持续失败,请联系管理员并提供失败时间。';
|
|
25
|
+
default:
|
|
26
|
+
return `${failure.code}:${failure.message}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Turn a verification-code request failure into a safe next action. */
|
|
30
|
+
function registrationOtpFailureMessage(failure) {
|
|
31
|
+
switch (failure.code) {
|
|
32
|
+
case 'rate-limited':
|
|
33
|
+
return '验证码发送过于频繁,请等待限流解除后再重新获取。';
|
|
34
|
+
case 'invalid-request':
|
|
35
|
+
return '无法发送验证码,请检查手机号和 Handle 后重试。';
|
|
36
|
+
case 'forbidden':
|
|
37
|
+
return '当前 AWiki 服务未向该手机号开放注册,请联系管理员。';
|
|
38
|
+
case 'network':
|
|
39
|
+
return '无法连接 AWiki 服务,请检查网络后重试。';
|
|
40
|
+
case 'remote':
|
|
41
|
+
return 'AWiki 服务暂时无法发送验证码,请稍后重试。';
|
|
42
|
+
default:
|
|
43
|
+
return registrationFailureMessage(failure);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const INITIAL_VIEW = Object.freeze({
|
|
47
|
+
status: 'cold',
|
|
48
|
+
sessionStatus: 'unregistered',
|
|
49
|
+
identity: null,
|
|
50
|
+
conversations: Object.freeze([]),
|
|
51
|
+
conversationsHasMore: false,
|
|
52
|
+
selectedConversationId: null,
|
|
53
|
+
messages: Object.freeze([]),
|
|
54
|
+
historyHasMore: false,
|
|
55
|
+
pending: null,
|
|
56
|
+
error: null,
|
|
57
|
+
attachmentMaxBytes: 0,
|
|
58
|
+
summaries: Object.freeze({}),
|
|
59
|
+
});
|
|
60
|
+
/** Turn a closed Host summary failure into one actionable Chinese message. */
|
|
61
|
+
function summaryFailureMessage(failure) {
|
|
62
|
+
switch (failure.code) {
|
|
63
|
+
case 'summary-unavailable': return 'AI 总结暂不可用,请先在 Harness 设置中配置可用的默认模型。';
|
|
64
|
+
case 'summary-timeout': return 'AI 总结超时,请稍后重新生成。';
|
|
65
|
+
case 'summary-cancelled': return 'AI 总结已取消,请重新生成。';
|
|
66
|
+
case 'summary-invalid-output': return '模型没有返回有效的结构化摘要,请重新生成。';
|
|
67
|
+
case 'summary-failed': return '暂时无法生成 AI 总结,请检查模型连接后重试。';
|
|
68
|
+
default: return `${failure.code}:${failure.message}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Flatten the carrier and business result once for every controller caller. */
|
|
72
|
+
async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`) {
|
|
73
|
+
try {
|
|
74
|
+
const carried = await operation();
|
|
75
|
+
if (!carried.ok)
|
|
76
|
+
return { ok: false, error: `连接 AWiki Host 失败:${carried.error.message}` };
|
|
77
|
+
if (!carried.value.ok) {
|
|
78
|
+
return { ok: false, error: failureMessage(carried.value.error) };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, value: carried.value.value };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: error instanceof Error ? `AWiki 调用失败:${error.message}` : 'AWiki 调用失败',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Append unique values while retaining existing references. */
|
|
90
|
+
function appendUnique(current, incoming, id) {
|
|
91
|
+
const seen = new Set(current.map(id));
|
|
92
|
+
const appended = [];
|
|
93
|
+
for (const value of incoming) {
|
|
94
|
+
const key = id(value);
|
|
95
|
+
if (seen.has(key))
|
|
96
|
+
continue;
|
|
97
|
+
seen.add(key);
|
|
98
|
+
appended.push(value);
|
|
99
|
+
}
|
|
100
|
+
return [...current, ...appended];
|
|
101
|
+
}
|
|
102
|
+
/** Prepend unique values while retaining the existing tail. */
|
|
103
|
+
function prependUnique(current, incoming, id) {
|
|
104
|
+
const seen = new Set(current.map(id));
|
|
105
|
+
const prepended = [];
|
|
106
|
+
for (const value of incoming) {
|
|
107
|
+
const key = id(value);
|
|
108
|
+
if (seen.has(key))
|
|
109
|
+
continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
prepended.push(value);
|
|
112
|
+
}
|
|
113
|
+
return [...prepended, ...current];
|
|
114
|
+
}
|
|
115
|
+
/** Strip surrounding space and a leading @ from a Handle the user typed. */
|
|
116
|
+
function normalizeHandle(value) {
|
|
117
|
+
return value.trim().replace(/^@+/u, '');
|
|
118
|
+
}
|
|
119
|
+
/** Compare a typed Handle against the deployment identity, including domain suffix form. */
|
|
120
|
+
function sameIdentity(identity, peer) {
|
|
121
|
+
const own = identity.handle.toLowerCase();
|
|
122
|
+
const target = peer.toLowerCase();
|
|
123
|
+
return own === target || own.startsWith(`${target}.`) || target.startsWith(`${own}.`);
|
|
124
|
+
}
|
|
125
|
+
/** Keys that can identify one direct peer in the current roster. */
|
|
126
|
+
function directPeerKeys(conversation) {
|
|
127
|
+
const keys = [conversation.peerDid, conversation.title];
|
|
128
|
+
if (conversation.peerHandle !== undefined)
|
|
129
|
+
keys.push(conversation.peerHandle);
|
|
130
|
+
if (conversation.displayName !== undefined)
|
|
131
|
+
keys.push(conversation.displayName);
|
|
132
|
+
return keys.map(value => value.replace(/^@/u, '').toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
/** Find an existing direct conversation for one typed Handle or DID. */
|
|
135
|
+
function findDirect(conversations, peer) {
|
|
136
|
+
const key = peer.toLowerCase();
|
|
137
|
+
return conversations.find((conversation) => (conversation.kind === 'direct' && directPeerKeys(conversation).includes(key)));
|
|
138
|
+
}
|
|
139
|
+
/** Keep a profile refreshed from WNS when a slower roster page still carries an older message snapshot. */
|
|
140
|
+
function preserveDirectProfile(incoming, current) {
|
|
141
|
+
if (incoming.kind !== 'direct' || current?.kind !== 'direct')
|
|
142
|
+
return incoming;
|
|
143
|
+
const displayName = current.displayName ?? incoming.displayName;
|
|
144
|
+
const peerHandle = current.peerHandle ?? incoming.peerHandle;
|
|
145
|
+
return {
|
|
146
|
+
...incoming,
|
|
147
|
+
title: displayName ?? peerHandle ?? incoming.title,
|
|
148
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
149
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Resolve one listed conversation into the send target accepted by AWiki. */
|
|
153
|
+
function targetOf(conversation) {
|
|
154
|
+
return conversation.kind === 'direct'
|
|
155
|
+
? { kind: 'direct', peer: conversation.peerDid }
|
|
156
|
+
: { kind: 'group', group: conversation.groupDid };
|
|
157
|
+
}
|
|
158
|
+
/** Browser object layer for identity, conversations, history, and polling. */
|
|
159
|
+
export class AwikiController {
|
|
160
|
+
remote;
|
|
161
|
+
view = INITIAL_VIEW;
|
|
162
|
+
listeners = new Set();
|
|
163
|
+
config = null;
|
|
164
|
+
conversationsCursor;
|
|
165
|
+
historyCursor;
|
|
166
|
+
timer;
|
|
167
|
+
generation = 0;
|
|
168
|
+
disposed = false;
|
|
169
|
+
polling = false;
|
|
170
|
+
unreadAtOpen = new Map();
|
|
171
|
+
/** @param remote - generated Host Remote namespace. */
|
|
172
|
+
constructor(remote) {
|
|
173
|
+
this.remote = remote;
|
|
174
|
+
}
|
|
175
|
+
/** Return the cached immutable view. */
|
|
176
|
+
getSnapshot = () => this.view;
|
|
177
|
+
/** Subscribe to view replacement. */
|
|
178
|
+
subscribe = (listener) => {
|
|
179
|
+
this.listeners.add(listener);
|
|
180
|
+
return () => { this.listeners.delete(listener); };
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Load Host policy and identity, then start polling while the drawer remains open.
|
|
184
|
+
* @returns successful readiness or one display-safe Host failure.
|
|
185
|
+
*/
|
|
186
|
+
async open() {
|
|
187
|
+
if (this.disposed)
|
|
188
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
189
|
+
this.close();
|
|
190
|
+
const generation = this.generation;
|
|
191
|
+
this.publish({ ...INITIAL_VIEW, status: 'loading' });
|
|
192
|
+
const config = await call(() => this.remote.getConfig());
|
|
193
|
+
if (!this.current(generation))
|
|
194
|
+
return { ok: true, value: undefined };
|
|
195
|
+
if (!config.ok)
|
|
196
|
+
return this.fail(config.error);
|
|
197
|
+
this.config = config.value;
|
|
198
|
+
const session = await call(() => this.remote.getSession());
|
|
199
|
+
if (!this.current(generation))
|
|
200
|
+
return { ok: true, value: undefined };
|
|
201
|
+
if (!session.ok)
|
|
202
|
+
return this.fail(session.error);
|
|
203
|
+
const identity = session.value.status === 'active' ? session.value.identity : null;
|
|
204
|
+
this.publish({
|
|
205
|
+
...this.view,
|
|
206
|
+
status: 'ready',
|
|
207
|
+
sessionStatus: session.value.status,
|
|
208
|
+
identity,
|
|
209
|
+
error: null,
|
|
210
|
+
attachmentMaxBytes: config.value.attachmentMaxBytes,
|
|
211
|
+
});
|
|
212
|
+
if (identity !== null) {
|
|
213
|
+
const listed = await this.refreshConversations(generation);
|
|
214
|
+
if (!listed.ok)
|
|
215
|
+
return listed;
|
|
216
|
+
}
|
|
217
|
+
if (this.current(generation)) {
|
|
218
|
+
this.timer = setInterval(() => { void this.poll(generation); }, this.config.pollIntervalMs);
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, value: undefined };
|
|
221
|
+
}
|
|
222
|
+
/** Sign out locally while retaining the SDK-owned identity and database. */
|
|
223
|
+
async logout(request) {
|
|
224
|
+
if (this.disposed)
|
|
225
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
226
|
+
const result = await call(() => this.remote.logout(request));
|
|
227
|
+
if (!result.ok)
|
|
228
|
+
return result;
|
|
229
|
+
this.close();
|
|
230
|
+
this.conversationsCursor = undefined;
|
|
231
|
+
this.historyCursor = undefined;
|
|
232
|
+
this.publish({
|
|
233
|
+
...INITIAL_VIEW,
|
|
234
|
+
status: 'ready',
|
|
235
|
+
sessionStatus: 'signed-out',
|
|
236
|
+
attachmentMaxBytes: this.config?.attachmentMaxBytes ?? 0,
|
|
237
|
+
});
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
/** Resume the preserved local identity and reload its conversations. */
|
|
241
|
+
async login() {
|
|
242
|
+
if (this.disposed)
|
|
243
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
244
|
+
const result = await call(() => this.remote.login());
|
|
245
|
+
if (!result.ok)
|
|
246
|
+
return result;
|
|
247
|
+
if (result.value.status !== 'active')
|
|
248
|
+
return { ok: false, error: '本机没有可恢复的 AWiki 身份' };
|
|
249
|
+
const opened = await this.open();
|
|
250
|
+
return opened.ok ? result : { ok: false, error: opened.error };
|
|
251
|
+
}
|
|
252
|
+
/** Stop polling and invalidate all in-flight drawer work. */
|
|
253
|
+
close() {
|
|
254
|
+
this.generation += 1;
|
|
255
|
+
if (this.timer !== undefined)
|
|
256
|
+
clearInterval(this.timer);
|
|
257
|
+
this.timer = undefined;
|
|
258
|
+
this.polling = false;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Request one phone verification challenge.
|
|
262
|
+
* @param request - desired Handle and verification phone number.
|
|
263
|
+
* @returns challenge retry metadata or one display-safe failure.
|
|
264
|
+
*/
|
|
265
|
+
async sendRegistrationOtp(request) {
|
|
266
|
+
return this.withPending('发送验证码', () => call(() => this.remote.sendRegistrationOtp(request), registrationOtpFailureMessage));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Register the deployment identity and populate the initial conversation list.
|
|
270
|
+
* @param request - verified Handle, phone number, and one-time code.
|
|
271
|
+
* @returns the registered public identity or one display-safe failure.
|
|
272
|
+
*/
|
|
273
|
+
async registerIdentity(request) {
|
|
274
|
+
const generation = this.generation;
|
|
275
|
+
const result = await this.withPending('注册身份', () => call(() => this.remote.registerIdentity(request), registrationFailureMessage));
|
|
276
|
+
if (!result.ok)
|
|
277
|
+
return result;
|
|
278
|
+
if (!this.current(generation))
|
|
279
|
+
return result;
|
|
280
|
+
this.publish({ ...this.view, sessionStatus: 'active', identity: result.value, error: null });
|
|
281
|
+
await this.refreshConversations(generation);
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Update the deployment identity's public display name.
|
|
286
|
+
* @param displayName - replacement display name selected by the user.
|
|
287
|
+
* @returns the updated identity or one display-safe failure.
|
|
288
|
+
*/
|
|
289
|
+
async updateDisplayName(displayName) {
|
|
290
|
+
const normalized = displayName.trim();
|
|
291
|
+
const length = Array.from(normalized).length;
|
|
292
|
+
if (length === 0)
|
|
293
|
+
return this.fail('请输入昵称');
|
|
294
|
+
if (length > 50)
|
|
295
|
+
return this.fail('昵称不能超过 50 个字符');
|
|
296
|
+
const generation = this.generation;
|
|
297
|
+
const result = await this.withPending('修改昵称', () => call(() => this.remote.updateDisplayName({ displayName: normalized })));
|
|
298
|
+
if (!result.ok || !this.current(generation))
|
|
299
|
+
return result;
|
|
300
|
+
this.publish({ ...this.view, identity: result.value, error: null });
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Load another page of the conversation roster.
|
|
305
|
+
* @returns successful pagination or one display-safe failure.
|
|
306
|
+
*/
|
|
307
|
+
async loadMoreConversations() {
|
|
308
|
+
const generation = this.generation;
|
|
309
|
+
const result = await this.withPending('加载更多会话', () => call(() => this.remote.listConversations(this.conversationsCursor === undefined ? {} : { cursor: this.conversationsCursor })));
|
|
310
|
+
if (!result.ok)
|
|
311
|
+
return result;
|
|
312
|
+
if (!this.current(generation))
|
|
313
|
+
return { ok: true, value: undefined };
|
|
314
|
+
this.conversationsCursor = result.value.nextCursor;
|
|
315
|
+
this.publish({
|
|
316
|
+
...this.view,
|
|
317
|
+
conversations: appendUnique(this.view.conversations, result.value.items, value => value.id),
|
|
318
|
+
conversationsHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
|
|
319
|
+
});
|
|
320
|
+
return { ok: true, value: undefined };
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Look up a Handle or DID, then open the matching direct conversation.
|
|
324
|
+
* @param handle - peer Handle or DID typed by the user.
|
|
325
|
+
* @returns successful selection or one display-safe lookup failure.
|
|
326
|
+
*/
|
|
327
|
+
async startDirectChat(handle) {
|
|
328
|
+
if (this.disposed)
|
|
329
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
330
|
+
const peer = normalizeHandle(handle);
|
|
331
|
+
if (peer === '')
|
|
332
|
+
return this.fail('请输入 Handle');
|
|
333
|
+
const identity = this.view.identity;
|
|
334
|
+
if (identity === null)
|
|
335
|
+
return this.fail('请先注册 AWiki 身份');
|
|
336
|
+
if (sameIdentity(identity, peer))
|
|
337
|
+
return this.fail('不能向自己发起私聊');
|
|
338
|
+
const existing = findDirect(this.view.conversations, peer);
|
|
339
|
+
if (existing !== undefined)
|
|
340
|
+
return this.selectConversation(existing.id);
|
|
341
|
+
const generation = this.generation;
|
|
342
|
+
const resolved = await this.withPending('查找用户', () => call(() => this.remote.resolvePeer({ peer })));
|
|
343
|
+
if (!resolved.ok) {
|
|
344
|
+
return resolved.error.startsWith('not-found') ? this.fail('该 Handle 不存在') : resolved;
|
|
345
|
+
}
|
|
346
|
+
if (!this.current(generation))
|
|
347
|
+
return { ok: true, value: undefined };
|
|
348
|
+
if (resolved.value.did === identity.did)
|
|
349
|
+
return this.fail('不能向自己发起私聊');
|
|
350
|
+
await this.refreshConversations(generation);
|
|
351
|
+
if (!this.current(generation))
|
|
352
|
+
return { ok: true, value: undefined };
|
|
353
|
+
const listed = this.view.conversations.find(conversation => conversation.id === resolved.value.conversationId)
|
|
354
|
+
?? findDirect(this.view.conversations, resolved.value.handle ?? peer)
|
|
355
|
+
?? findDirect(this.view.conversations, resolved.value.did);
|
|
356
|
+
if (listed !== undefined)
|
|
357
|
+
return this.selectConversation(listed.id);
|
|
358
|
+
const conversation = {
|
|
359
|
+
kind: 'direct',
|
|
360
|
+
id: resolved.value.conversationId,
|
|
361
|
+
peerDid: resolved.value.did,
|
|
362
|
+
title: resolved.value.displayName ?? resolved.value.handle ?? resolved.value.did,
|
|
363
|
+
...resolved.value.handle === undefined ? {} : { peerHandle: resolved.value.handle },
|
|
364
|
+
...resolved.value.displayName === undefined ? {} : { displayName: resolved.value.displayName },
|
|
365
|
+
};
|
|
366
|
+
this.publish({
|
|
367
|
+
...this.view,
|
|
368
|
+
conversations: [conversation, ...this.view.conversations],
|
|
369
|
+
error: null,
|
|
370
|
+
});
|
|
371
|
+
return this.selectConversation(conversation.id);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Select a conversation and load its newest history page.
|
|
375
|
+
* @param conversationId - selected conversation, or `null` to return to the roster.
|
|
376
|
+
* @returns successful selection or one display-safe history failure.
|
|
377
|
+
*/
|
|
378
|
+
async selectConversation(conversationId) {
|
|
379
|
+
this.historyCursor = undefined;
|
|
380
|
+
const selected = conversationId === null
|
|
381
|
+
? undefined
|
|
382
|
+
: this.view.conversations.find(conversation => conversation.id === conversationId);
|
|
383
|
+
if (selected !== undefined)
|
|
384
|
+
this.unreadAtOpen.set(selected.id, selected.unreadCount ?? 0);
|
|
385
|
+
this.publish({ ...this.view, selectedConversationId: conversationId, messages: [], historyHasMore: false, error: null });
|
|
386
|
+
if (conversationId === null)
|
|
387
|
+
return { ok: true, value: undefined };
|
|
388
|
+
const generation = this.generation;
|
|
389
|
+
const profile = selected?.kind === 'direct'
|
|
390
|
+
? call(() => this.remote.resolvePeer({ peer: selected.peerDid }))
|
|
391
|
+
: Promise.resolve(null);
|
|
392
|
+
const [loaded, refreshed] = await Promise.all([this.loadHistory(false), profile]);
|
|
393
|
+
if (refreshed?.ok
|
|
394
|
+
&& this.current(generation)
|
|
395
|
+
&& this.view.selectedConversationId === conversationId
|
|
396
|
+
&& selected?.kind === 'direct'
|
|
397
|
+
&& refreshed.value.did === selected.peerDid
|
|
398
|
+
&& refreshed.value.conversationId === selected.id) {
|
|
399
|
+
this.publish({
|
|
400
|
+
...this.view,
|
|
401
|
+
conversations: this.view.conversations.map((conversation) => {
|
|
402
|
+
if (conversation.id !== conversationId || conversation.kind !== 'direct')
|
|
403
|
+
return conversation;
|
|
404
|
+
const displayName = refreshed.value.displayName ?? conversation.displayName;
|
|
405
|
+
const peerHandle = refreshed.value.handle ?? conversation.peerHandle;
|
|
406
|
+
return {
|
|
407
|
+
...conversation,
|
|
408
|
+
title: displayName ?? peerHandle ?? conversation.title,
|
|
409
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
410
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
411
|
+
};
|
|
412
|
+
}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (!loaded.ok)
|
|
416
|
+
return loaded;
|
|
417
|
+
const marked = await call(() => this.remote.markConversationRead({ conversationId }));
|
|
418
|
+
if (!marked.ok)
|
|
419
|
+
return this.fail(marked.error);
|
|
420
|
+
if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
|
|
421
|
+
return { ok: true, value: undefined };
|
|
422
|
+
}
|
|
423
|
+
this.publish({
|
|
424
|
+
...this.view,
|
|
425
|
+
conversations: this.view.conversations.map(conversation => conversation.id === conversationId
|
|
426
|
+
? { ...conversation, unreadCount: 0 }
|
|
427
|
+
: conversation),
|
|
428
|
+
error: null,
|
|
429
|
+
});
|
|
430
|
+
return { ok: true, value: undefined };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Load one older history page before the currently rendered messages.
|
|
434
|
+
* @returns successful pagination or one display-safe failure.
|
|
435
|
+
*/
|
|
436
|
+
loadOlderHistory() {
|
|
437
|
+
return this.loadHistory(true);
|
|
438
|
+
}
|
|
439
|
+
/** Generate or regenerate the selected conversation's runtime-only summary. */
|
|
440
|
+
async summarizeConversation() {
|
|
441
|
+
const conversation = this.selectedConversation();
|
|
442
|
+
if (conversation === undefined)
|
|
443
|
+
return this.fail('请先选择会话');
|
|
444
|
+
const conversationId = conversation.id;
|
|
445
|
+
const generation = this.generation;
|
|
446
|
+
this.setSummary(conversationId, { status: 'loading', collapsed: false, stale: false });
|
|
447
|
+
const unreadCountAtOpen = this.unreadAtOpen.get(conversationId) ?? 0;
|
|
448
|
+
const result = await call(() => this.remote.summarizeConversation({
|
|
449
|
+
conversationId,
|
|
450
|
+
...(unreadCountAtOpen > 0 ? { unreadCountAtOpen } : {}),
|
|
451
|
+
}), summaryFailureMessage);
|
|
452
|
+
if (!this.current(generation))
|
|
453
|
+
return result;
|
|
454
|
+
if (!result.ok) {
|
|
455
|
+
this.setSummary(conversationId, {
|
|
456
|
+
status: 'error',
|
|
457
|
+
collapsed: false,
|
|
458
|
+
stale: false,
|
|
459
|
+
error: result.error,
|
|
460
|
+
});
|
|
461
|
+
return result;
|
|
462
|
+
}
|
|
463
|
+
this.setSummary(conversationId, {
|
|
464
|
+
status: 'success',
|
|
465
|
+
collapsed: false,
|
|
466
|
+
stale: false,
|
|
467
|
+
result: result.value,
|
|
468
|
+
});
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
/** Expand or collapse one cached summary without another model call. */
|
|
472
|
+
setSummaryCollapsed(conversationId, collapsed) {
|
|
473
|
+
const current = this.view.summaries[conversationId];
|
|
474
|
+
if (current === undefined || current.status === 'idle')
|
|
475
|
+
return;
|
|
476
|
+
this.setSummary(conversationId, { ...current, collapsed });
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Send one text message to the selected direct or group conversation.
|
|
480
|
+
* @param text - non-empty text prepared by the composer.
|
|
481
|
+
* @returns successful delivery or one display-safe failure.
|
|
482
|
+
*/
|
|
483
|
+
async sendText(text) {
|
|
484
|
+
const conversation = this.selectedConversation();
|
|
485
|
+
if (conversation === undefined)
|
|
486
|
+
return this.fail('请先选择会话');
|
|
487
|
+
const conversationId = conversation.id;
|
|
488
|
+
const generation = this.generation;
|
|
489
|
+
const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
|
|
490
|
+
target: targetOf(conversation), text, idempotencyKey: crypto.randomUUID(),
|
|
491
|
+
})));
|
|
492
|
+
if (!result.ok)
|
|
493
|
+
return result;
|
|
494
|
+
if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
|
|
495
|
+
return { ok: true, value: undefined };
|
|
496
|
+
}
|
|
497
|
+
this.appendMessage(result.value);
|
|
498
|
+
return { ok: true, value: undefined };
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Send one already-read browser file without retaining its bytes in the view.
|
|
502
|
+
* @param file - JSON-safe file name, MIME type, base64 bytes, and optional caption.
|
|
503
|
+
* @returns successful delivery or one display-safe failure.
|
|
504
|
+
*/
|
|
505
|
+
async sendAttachment(file) {
|
|
506
|
+
const conversation = this.selectedConversation();
|
|
507
|
+
if (conversation === undefined)
|
|
508
|
+
return this.fail('请先选择会话');
|
|
509
|
+
const conversationId = conversation.id;
|
|
510
|
+
const generation = this.generation;
|
|
511
|
+
const request = {
|
|
512
|
+
target: targetOf(conversation),
|
|
513
|
+
fileName: file.fileName,
|
|
514
|
+
mimeType: file.mimeType,
|
|
515
|
+
bytesBase64: file.bytesBase64,
|
|
516
|
+
...(file.caption === undefined ? {} : { caption: file.caption }),
|
|
517
|
+
idempotencyKey: crypto.randomUUID(),
|
|
518
|
+
};
|
|
519
|
+
const result = await this.withPending('发送附件', () => call(() => this.remote.sendAttachment(request)));
|
|
520
|
+
if (!result.ok)
|
|
521
|
+
return result;
|
|
522
|
+
if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
|
|
523
|
+
return { ok: true, value: undefined };
|
|
524
|
+
}
|
|
525
|
+
this.appendMessage(result.value);
|
|
526
|
+
return { ok: true, value: undefined };
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Download verified attachment bytes without publishing them into controller state.
|
|
530
|
+
* @param messageId - message that grants access to the attachment.
|
|
531
|
+
* @param attachmentId - attachment selected from that message.
|
|
532
|
+
* @returns verified attachment metadata and bytes, or one display-safe failure.
|
|
533
|
+
*/
|
|
534
|
+
async downloadAttachment(messageId, attachmentId) {
|
|
535
|
+
if (this.disposed)
|
|
536
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
537
|
+
const generation = this.generation;
|
|
538
|
+
const result = await call(() => this.remote.downloadAttachment({ attachmentId, messageId }));
|
|
539
|
+
return this.current(generation) ? result : { ok: false, error: 'AWiki 已关闭' };
|
|
540
|
+
}
|
|
541
|
+
/** Clear Host-owned local data and immediately remove every cached browser projection. */
|
|
542
|
+
async clearLocalData(request) {
|
|
543
|
+
if (this.disposed)
|
|
544
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
545
|
+
const result = await call(() => this.remote.clearLocalData(request));
|
|
546
|
+
if (!result.ok)
|
|
547
|
+
return result;
|
|
548
|
+
this.close();
|
|
549
|
+
this.config = null;
|
|
550
|
+
this.conversationsCursor = undefined;
|
|
551
|
+
this.historyCursor = undefined;
|
|
552
|
+
this.unreadAtOpen.clear();
|
|
553
|
+
this.publish({ ...INITIAL_VIEW, status: 'ready' });
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
/** Stop timers, invalidate work, and drop subscribers during HMR unload. */
|
|
557
|
+
dispose() {
|
|
558
|
+
this.disposed = true;
|
|
559
|
+
this.close();
|
|
560
|
+
this.listeners.clear();
|
|
561
|
+
}
|
|
562
|
+
async refreshConversations(generation) {
|
|
563
|
+
const result = await call(() => this.remote.listConversations({}));
|
|
564
|
+
if (!this.current(generation))
|
|
565
|
+
return { ok: true, value: undefined };
|
|
566
|
+
if (!result.ok)
|
|
567
|
+
return this.fail(result.error);
|
|
568
|
+
const firstPage = this.view.conversations.length === 0;
|
|
569
|
+
if (firstPage)
|
|
570
|
+
this.conversationsCursor = result.value.nextCursor;
|
|
571
|
+
const refreshed = result.value.items.map(incoming => preserveDirectProfile(incoming, this.view.conversations.find(current => current.id === incoming.id)));
|
|
572
|
+
this.publish({
|
|
573
|
+
...this.view,
|
|
574
|
+
conversations: firstPage
|
|
575
|
+
? refreshed
|
|
576
|
+
: appendUnique(refreshed, this.view.conversations, value => value.id),
|
|
577
|
+
conversationsHasMore: firstPage
|
|
578
|
+
? result.value.hasMore && result.value.nextCursor !== undefined
|
|
579
|
+
: this.view.conversationsHasMore,
|
|
580
|
+
error: null,
|
|
581
|
+
});
|
|
582
|
+
return { ok: true, value: undefined };
|
|
583
|
+
}
|
|
584
|
+
async loadHistory(older) {
|
|
585
|
+
const conversationId = this.view.selectedConversationId;
|
|
586
|
+
if (conversationId === null)
|
|
587
|
+
return this.fail('请先选择会话');
|
|
588
|
+
const generation = this.generation;
|
|
589
|
+
const request = {
|
|
590
|
+
conversationId,
|
|
591
|
+
...(older && this.historyCursor !== undefined ? { cursor: this.historyCursor } : {}),
|
|
592
|
+
};
|
|
593
|
+
const result = await this.withPending(older ? '加载更早消息' : '加载消息', () => call(() => this.remote.getHistory(request)));
|
|
594
|
+
if (!result.ok)
|
|
595
|
+
return result;
|
|
596
|
+
if (!this.current(generation))
|
|
597
|
+
return { ok: true, value: undefined };
|
|
598
|
+
if (this.view.selectedConversationId !== conversationId)
|
|
599
|
+
return { ok: true, value: undefined };
|
|
600
|
+
this.historyCursor = result.value.nextCursor;
|
|
601
|
+
const messages = older
|
|
602
|
+
? prependUnique(this.view.messages, result.value.items, value => value.id)
|
|
603
|
+
: result.value.items;
|
|
604
|
+
this.publish({
|
|
605
|
+
...this.view,
|
|
606
|
+
// SDK pages are chronological. A continuation page contains older
|
|
607
|
+
// messages, so it is prepended to the already rendered chronological tail.
|
|
608
|
+
messages,
|
|
609
|
+
historyHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
|
|
610
|
+
summaries: this.staleSummaries(conversationId, messages),
|
|
611
|
+
});
|
|
612
|
+
return { ok: true, value: undefined };
|
|
613
|
+
}
|
|
614
|
+
async poll(generation) {
|
|
615
|
+
if (this.polling || !this.current(generation) || this.view.identity === null)
|
|
616
|
+
return;
|
|
617
|
+
this.polling = true;
|
|
618
|
+
try {
|
|
619
|
+
await this.refreshConversations(generation);
|
|
620
|
+
const selected = this.view.selectedConversationId;
|
|
621
|
+
if (selected === null || !this.current(generation))
|
|
622
|
+
return;
|
|
623
|
+
const result = await call(() => this.remote.getHistory({ conversationId: selected }));
|
|
624
|
+
if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
|
|
625
|
+
return;
|
|
626
|
+
const messages = appendUnique(this.view.messages, result.value.items, value => value.id);
|
|
627
|
+
const added = messages.length - this.view.messages.length;
|
|
628
|
+
if (added > 0 && (this.unreadAtOpen.get(selected) ?? 0) > 0) {
|
|
629
|
+
this.unreadAtOpen.set(selected, (this.unreadAtOpen.get(selected) ?? 0) + added);
|
|
630
|
+
}
|
|
631
|
+
this.publish({
|
|
632
|
+
...this.view,
|
|
633
|
+
messages,
|
|
634
|
+
summaries: this.staleSummaries(selected, messages),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
this.polling = false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async withPending(label, operation) {
|
|
642
|
+
if (this.disposed)
|
|
643
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
644
|
+
const generation = this.generation;
|
|
645
|
+
this.publish({ ...this.view, pending: label, error: null });
|
|
646
|
+
const result = await operation();
|
|
647
|
+
if (!this.current(generation))
|
|
648
|
+
return result;
|
|
649
|
+
this.publish({ ...this.view, pending: null, error: result.ok ? null : result.error });
|
|
650
|
+
return result;
|
|
651
|
+
}
|
|
652
|
+
appendMessage(message) {
|
|
653
|
+
const messages = appendUnique(this.view.messages, [message], value => value.id);
|
|
654
|
+
if ((this.unreadAtOpen.get(message.conversationId) ?? 0) > 0 && messages.length > this.view.messages.length) {
|
|
655
|
+
this.unreadAtOpen.set(message.conversationId, (this.unreadAtOpen.get(message.conversationId) ?? 0) + 1);
|
|
656
|
+
}
|
|
657
|
+
this.publish({
|
|
658
|
+
...this.view,
|
|
659
|
+
messages,
|
|
660
|
+
summaries: this.staleSummaries(message.conversationId, messages),
|
|
661
|
+
error: null,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
setSummary(conversationId, summary) {
|
|
665
|
+
this.publish({
|
|
666
|
+
...this.view,
|
|
667
|
+
summaries: Object.freeze({ ...this.view.summaries, [conversationId]: Object.freeze(summary) }),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
staleSummaries(conversationId, messages) {
|
|
671
|
+
const summary = this.view.summaries[conversationId];
|
|
672
|
+
if (summary?.status !== 'success' || summary.result === undefined || summary.stale)
|
|
673
|
+
return this.view.summaries;
|
|
674
|
+
if (!messages.some(message => message.sentAt > summary.result.range.endedAt))
|
|
675
|
+
return this.view.summaries;
|
|
676
|
+
return Object.freeze({
|
|
677
|
+
...this.view.summaries,
|
|
678
|
+
[conversationId]: Object.freeze({ ...summary, stale: true }),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
selectedConversation() {
|
|
682
|
+
const selected = this.view.selectedConversationId;
|
|
683
|
+
return selected === null ? undefined : this.view.conversations.find(value => value.id === selected);
|
|
684
|
+
}
|
|
685
|
+
fail(error) {
|
|
686
|
+
this.publish({ ...this.view, status: this.view.status === 'loading' ? 'error' : this.view.status, pending: null, error });
|
|
687
|
+
return { ok: false, error };
|
|
688
|
+
}
|
|
689
|
+
current(generation) {
|
|
690
|
+
return !this.disposed && generation === this.generation;
|
|
691
|
+
}
|
|
692
|
+
publish(view) {
|
|
693
|
+
/* v8 ignore next -- every asynchronous and public mutation path checks disposal before publishing. */
|
|
694
|
+
if (this.disposed)
|
|
695
|
+
return;
|
|
696
|
+
this.view = Object.freeze(view);
|
|
697
|
+
for (const listener of [...this.listeners])
|
|
698
|
+
listener();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
//# sourceMappingURL=controller.js.map
|