@keyflow2/keyflow-kit-wx-reply 0.2.9
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/API.md +182 -0
- package/LICENSE +202 -0
- package/UPPER_LAYER_APPS.md +158 -0
- package/icons/icon-128.png +0 -0
- package/icons/icon-48.png +0 -0
- package/icons/icon-96.png +0 -0
- package/manifest.json +60 -0
- package/package.json +25 -0
- package/ui/app/index.html +383 -0
- package/ui/app/main.js +1080 -0
- package/ui/app/styles.css +825 -0
package/ui/app/main.js
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
/* global FunctionKitRuntimeSDK, PetiteVue */
|
|
2
|
+
|
|
3
|
+
(() => {
|
|
4
|
+
const kitId = "wx-reply";
|
|
5
|
+
const surface = "panel";
|
|
6
|
+
const DEFAULT_BASE_URL = "http://<HOST:PORT>";
|
|
7
|
+
const LOCAL_PROXY_ORIGIN = "https://function-kit.local";
|
|
8
|
+
const EXTERNAL_RESOURCE_PROXY_BASE = `${LOCAL_PROXY_ORIGIN}/assets/__external__/`;
|
|
9
|
+
const SETTINGS_KEY = "wxReply.settings.v2";
|
|
10
|
+
const RECENT_CONTACTS_KEY = "wxReply.recentContacts.v2";
|
|
11
|
+
const REPLY_COUNT = 2;
|
|
12
|
+
const DEFAULT_CONTEXT_MESSAGE_COUNT = 20;
|
|
13
|
+
const MIN_CONTEXT_MESSAGE_COUNT = 5;
|
|
14
|
+
const MAX_CONTEXT_MESSAGE_COUNT = 50;
|
|
15
|
+
const CONTEXT_MESSAGE_COUNT_STEP = 5;
|
|
16
|
+
|
|
17
|
+
FunctionKitRuntimeSDK.preview.installIfMissing({
|
|
18
|
+
kitId,
|
|
19
|
+
surface,
|
|
20
|
+
grantAll: true,
|
|
21
|
+
executionMode: "local-demo",
|
|
22
|
+
aiRequestHandler: ({ envelope }) => {
|
|
23
|
+
const payload = envelope?.payload ?? {};
|
|
24
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
|
25
|
+
const prompt = messages.map((item) => safeText(item?.content)).filter(Boolean).join("\n");
|
|
26
|
+
const tonePreset = prompt.includes("高情商") ? "eq" : prompt.includes("日常闲聊") ? "daily" : "work";
|
|
27
|
+
const personaMode = prompt.includes("融合我的画像") ? "merge-self" : "none";
|
|
28
|
+
return {
|
|
29
|
+
text: JSON.stringify({
|
|
30
|
+
replies: buildFallbackReplies({
|
|
31
|
+
latestMessage: "在吗?找你有点事。",
|
|
32
|
+
tonePreset,
|
|
33
|
+
personaMode,
|
|
34
|
+
count: REPLY_COUNT
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const kit = FunctionKitRuntimeSDK.createKit({
|
|
42
|
+
kitId,
|
|
43
|
+
surface,
|
|
44
|
+
debug: false,
|
|
45
|
+
connect: { timeoutMs: 20000, retries: 3 },
|
|
46
|
+
preview: { grantAll: true, executionMode: "local-demo" }
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const vm = {
|
|
50
|
+
kit,
|
|
51
|
+
screen: "picker",
|
|
52
|
+
settingsReturnTo: "picker",
|
|
53
|
+
toastTimer: null,
|
|
54
|
+
autoGenerateTimer: null,
|
|
55
|
+
avatarErrorMap: {},
|
|
56
|
+
|
|
57
|
+
busy: {
|
|
58
|
+
probing: false,
|
|
59
|
+
searching: false,
|
|
60
|
+
loadingPicker: false,
|
|
61
|
+
loadingConversation: false,
|
|
62
|
+
generating: false,
|
|
63
|
+
saving: false
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
toast: {
|
|
67
|
+
text: "",
|
|
68
|
+
kind: "info"
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
service: {
|
|
72
|
+
ok: null,
|
|
73
|
+
lastError: "",
|
|
74
|
+
state: {
|
|
75
|
+
time: null,
|
|
76
|
+
last_seq: null,
|
|
77
|
+
contacts_loaded: null,
|
|
78
|
+
self_username: "",
|
|
79
|
+
write_auth_enabled: false
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
settings: {
|
|
84
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
85
|
+
apiToken: "",
|
|
86
|
+
tonePreset: "work",
|
|
87
|
+
personaMode: "merge-self",
|
|
88
|
+
contextMessageCount: DEFAULT_CONTEXT_MESSAGE_COUNT
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
picker: {
|
|
92
|
+
query: "",
|
|
93
|
+
recentUsed: [],
|
|
94
|
+
sessions: [],
|
|
95
|
+
searchResults: []
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
compose: {
|
|
99
|
+
contact: emptyContact(),
|
|
100
|
+
message: "",
|
|
101
|
+
history: [],
|
|
102
|
+
replies: [],
|
|
103
|
+
replyIntent: "",
|
|
104
|
+
advancedOpen: false,
|
|
105
|
+
lastInsertedReplyId: "",
|
|
106
|
+
generationNonce: 0,
|
|
107
|
+
profiles: {
|
|
108
|
+
self: null,
|
|
109
|
+
contact: null
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
toneOptions: [
|
|
114
|
+
{ id: "work", label: "工作专业" },
|
|
115
|
+
{ id: "eq", label: "高情商" },
|
|
116
|
+
{ id: "daily", label: "日常闲聊" }
|
|
117
|
+
],
|
|
118
|
+
|
|
119
|
+
get showRecentUsedSection() {
|
|
120
|
+
return this.picker.recentUsed.length > 0;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
get serviceStatusText() {
|
|
124
|
+
if (this.busy.probing) return "正在探活真实后端...";
|
|
125
|
+
if (this.service.ok === true) return `已连接:last_seq=${this.service.state.last_seq ?? "-"}`;
|
|
126
|
+
if (this.service.ok === false) return this.service.lastError || "服务不可用";
|
|
127
|
+
return "尚未探活";
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
get replyLeadText() {
|
|
131
|
+
return this.settings.personaMode === "merge-self" ? "【懂你】" : "";
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
get contextMessageHint() {
|
|
135
|
+
const configured = normalizeContextMessageCount(this.settings.contextMessageCount);
|
|
136
|
+
const actual = Array.isArray(this.compose.history) ? Math.min(this.compose.history.length, configured) : 0;
|
|
137
|
+
if (actual > 0) {
|
|
138
|
+
return `当前会带上最近 ${actual} / ${configured} 条文本聊天记录,不只看最后一句。`;
|
|
139
|
+
}
|
|
140
|
+
return `默认会带上最近 ${configured} 条文本聊天记录,不只看最后一句。`;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async init() {
|
|
144
|
+
try {
|
|
145
|
+
await this.kit.connect();
|
|
146
|
+
} catch (_) {
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.kit?.bindings?.onInvoke) {
|
|
150
|
+
this.kit.bindings.onInvoke(({ invocation }) => {
|
|
151
|
+
this.handleBindingInvoke(invocation);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await this.loadStoredState();
|
|
156
|
+
await this.probeService({ silent: true });
|
|
157
|
+
if (this.service.ok) {
|
|
158
|
+
await this.refreshPickerData({ silent: true });
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async loadStoredState() {
|
|
163
|
+
if (!this.kit.hasPermission("storage.read")) return;
|
|
164
|
+
try {
|
|
165
|
+
const values = await this.kit.storage.get([SETTINGS_KEY, RECENT_CONTACTS_KEY]);
|
|
166
|
+
const storedSettings = normalizeSettings(values?.[SETTINGS_KEY]);
|
|
167
|
+
const storedRecentContacts = normalizeContactList(values?.[RECENT_CONTACTS_KEY]);
|
|
168
|
+
|
|
169
|
+
this.settings = { ...this.settings, ...storedSettings };
|
|
170
|
+
this.picker.recentUsed = storedRecentContacts.slice(0, 8);
|
|
171
|
+
} catch (_) {
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async persistState({ withBusy = false, showToast = false } = {}) {
|
|
176
|
+
if (!this.kit.hasPermission("storage.write")) return;
|
|
177
|
+
|
|
178
|
+
if (withBusy) this.busy.saving = true;
|
|
179
|
+
try {
|
|
180
|
+
await this.kit.storage.set({
|
|
181
|
+
[SETTINGS_KEY]: JSON.stringify(this.settings),
|
|
182
|
+
[RECENT_CONTACTS_KEY]: JSON.stringify(this.picker.recentUsed.slice(0, 8))
|
|
183
|
+
});
|
|
184
|
+
if (showToast) {
|
|
185
|
+
this.showToast("配置已保存", "success");
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.showToast(`保存失败:${formatError(error)}`, "error");
|
|
189
|
+
} finally {
|
|
190
|
+
if (withBusy) this.busy.saving = false;
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
handleBindingInvoke(invocation) {
|
|
195
|
+
this.screen = "picker";
|
|
196
|
+
this.compose.advancedOpen = false;
|
|
197
|
+
this.clearReplies();
|
|
198
|
+
|
|
199
|
+
const preferredView = safeText(invocation?.binding?.entry?.view);
|
|
200
|
+
if (preferredView === "picker") {
|
|
201
|
+
this.screen = "picker";
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
openSettings(returnTo = "picker") {
|
|
206
|
+
this.settingsReturnTo = returnTo;
|
|
207
|
+
this.screen = "settings";
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
goBackFromSettings() {
|
|
211
|
+
this.screen = this.settingsReturnTo || "picker";
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
goToPicker() {
|
|
215
|
+
this.screen = "picker";
|
|
216
|
+
this.compose.advancedOpen = false;
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
async attemptClose() {
|
|
220
|
+
if (!this.kit.hasPermission("panel.state.write") || !this.kit.panel?.updateState) {
|
|
221
|
+
this.showToast("当前宿主未开放面板关闭能力。", "error");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await this.kit.panel.updateState({ open: false, dismissed: true, visible: false });
|
|
227
|
+
} catch (_) {
|
|
228
|
+
this.showToast("当前宿主未实际处理关闭请求。", "error");
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
onSearchInput(value) {
|
|
233
|
+
this.picker.query = value;
|
|
234
|
+
const trimmed = normalizeSearchQuery(value);
|
|
235
|
+
if (!trimmed) {
|
|
236
|
+
this.clearSearch({ syncInput: false });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.picker.searchResults = this.searchLocalContacts(trimmed);
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
readLiveSearchQuery(query = undefined) {
|
|
244
|
+
const direct = safeText(query);
|
|
245
|
+
if (direct.trim()) {
|
|
246
|
+
return direct;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (typeof document !== "undefined") {
|
|
250
|
+
const liveValue = safeText(document.querySelector(".search-box__input")?.value);
|
|
251
|
+
if (liveValue.trim()) {
|
|
252
|
+
return liveValue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return safeText(this.picker.query);
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
canTriggerSearch() {
|
|
260
|
+
return !this.busy.searching && !!normalizeSearchQuery(this.readLiveSearchQuery());
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
clearSearch({ syncInput = true } = {}) {
|
|
264
|
+
this.picker.query = "";
|
|
265
|
+
this.picker.searchResults = [];
|
|
266
|
+
this.busy.searching = false;
|
|
267
|
+
|
|
268
|
+
if (syncInput && typeof document !== "undefined") {
|
|
269
|
+
const input = document.querySelector(".search-box__input");
|
|
270
|
+
if (input && input.value) {
|
|
271
|
+
input.value = "";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
async triggerSearch(query = undefined) {
|
|
277
|
+
const liveQuery = this.readLiveSearchQuery(query);
|
|
278
|
+
const trimmed = normalizeSearchQuery(liveQuery);
|
|
279
|
+
if (!trimmed) {
|
|
280
|
+
this.clearSearch();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.picker.query = liveQuery;
|
|
285
|
+
const localMatches = this.searchLocalContacts(trimmed);
|
|
286
|
+
this.picker.searchResults = localMatches;
|
|
287
|
+
this.busy.searching = true;
|
|
288
|
+
try {
|
|
289
|
+
const remote = await this.apiGet("/api/v1/contacts", { query: trimmed, limit: 24 }, { title: `搜索联系人:${trimmed}` });
|
|
290
|
+
const remoteMatches = normalizeContactList(remote?.items);
|
|
291
|
+
this.service.ok = true;
|
|
292
|
+
this.service.lastError = "";
|
|
293
|
+
this.picker.searchResults = mergeContacts(remoteMatches, localMatches).slice(0, 24);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
this.service.ok = false;
|
|
296
|
+
this.service.lastError = formatError(error);
|
|
297
|
+
this.picker.searchResults = localMatches;
|
|
298
|
+
if (!localMatches.length) {
|
|
299
|
+
this.showToast(`搜索失败:${formatError(error)}`, "error");
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
this.busy.searching = false;
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
searchLocalContacts(query) {
|
|
307
|
+
const trimmed = normalizeSearchQuery(query);
|
|
308
|
+
if (!trimmed) return [];
|
|
309
|
+
return mergeContacts(this.picker.recentUsed, this.picker.sessions)
|
|
310
|
+
.filter((contact) => matchesSearchQuery(contact, trimmed))
|
|
311
|
+
.slice(0, 24);
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async saveServiceSettings() {
|
|
315
|
+
this.settings.baseUrl = normalizeBaseUrl(this.settings.baseUrl);
|
|
316
|
+
await this.persistState({ withBusy: true });
|
|
317
|
+
await this.probeService({ silent: false });
|
|
318
|
+
if (this.service.ok) {
|
|
319
|
+
await this.refreshPickerData({ silent: true });
|
|
320
|
+
this.screen = this.settingsReturnTo || "picker";
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async probeService({ silent = false } = {}) {
|
|
325
|
+
this.busy.probing = true;
|
|
326
|
+
try {
|
|
327
|
+
const state = await this.apiGet("/api/v1/state", {}, { title: "探测微信数据服务" });
|
|
328
|
+
this.service.ok = true;
|
|
329
|
+
this.service.lastError = "";
|
|
330
|
+
this.service.state = {
|
|
331
|
+
...this.service.state,
|
|
332
|
+
...(state && typeof state === "object" ? state : {})
|
|
333
|
+
};
|
|
334
|
+
if (!silent) {
|
|
335
|
+
this.showToast("服务连接成功", "success");
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.service.ok = false;
|
|
339
|
+
this.service.lastError = formatError(error);
|
|
340
|
+
if (!silent) {
|
|
341
|
+
this.showToast(`服务不可用:${this.service.lastError}`, "error");
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
this.busy.probing = false;
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async refreshPickerData({ silent = false } = {}) {
|
|
349
|
+
if (!this.service.ok) return;
|
|
350
|
+
this.busy.loadingPicker = true;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const [recentResult, sessionResult] = await Promise.allSettled([this.loadRecentContacts(), this.loadSessions()]);
|
|
354
|
+
|
|
355
|
+
if (!silent && recentResult.status === "fulfilled" && sessionResult.status === "fulfilled") {
|
|
356
|
+
this.showToast("联系人与会话已刷新", "success");
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
this.busy.loadingPicker = false;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
async loadRecentContacts() {
|
|
364
|
+
const remote = await this.apiGet("/api/v1/recent_contacts", { limit: 10, offset: 0 }, { title: "拉取最近联系人" });
|
|
365
|
+
const merged = mergeContacts(normalizeContactList(remote?.items), this.picker.recentUsed);
|
|
366
|
+
this.picker.recentUsed = merged.slice(0, 8);
|
|
367
|
+
await this.persistState();
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async loadSessions() {
|
|
371
|
+
const remote = await this.apiGet("/api/v1/sessions", { limit: 30 }, { title: "拉取最近会话" });
|
|
372
|
+
this.picker.sessions = normalizeContactList(remote?.items);
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
async searchContacts(query = this.picker.query) {
|
|
376
|
+
await this.triggerSearch(query);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
async selectContact(rawContact) {
|
|
380
|
+
const contact = normalizeContact(rawContact);
|
|
381
|
+
if (!contact.username) {
|
|
382
|
+
this.showToast("联系人数据不完整。", "error");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.screen = "reply";
|
|
387
|
+
this.compose.contact = contact;
|
|
388
|
+
this.compose.replyIntent = "";
|
|
389
|
+
this.compose.advancedOpen = false;
|
|
390
|
+
this.compose.lastInsertedReplyId = "";
|
|
391
|
+
this.compose.generationNonce = 0;
|
|
392
|
+
this.clearReplies();
|
|
393
|
+
|
|
394
|
+
await this.rememberRecentContact(contact);
|
|
395
|
+
await this.loadConversation(contact);
|
|
396
|
+
if (this.compose.message) {
|
|
397
|
+
await this.generateReplies({ reason: "initial" });
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
async rememberRecentContact(contact) {
|
|
402
|
+
this.picker.recentUsed = mergeContacts([contact], this.picker.recentUsed).slice(0, 8);
|
|
403
|
+
await this.persistState();
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
await this.apiPost("/api/v1/recent_contacts", { username: contact.username }, { title: `记录最近联系人:${contact.displayName}` });
|
|
407
|
+
} catch (_) {
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async loadConversation(contact = this.compose.contact) {
|
|
412
|
+
if (!contact?.username) return;
|
|
413
|
+
|
|
414
|
+
this.busy.loadingConversation = true;
|
|
415
|
+
try {
|
|
416
|
+
const username = encodeURIComponent(contact.username);
|
|
417
|
+
const historyLimit = computeHistoryFetchLimit(this.settings.contextMessageCount);
|
|
418
|
+
const historyPromise = this.apiGet(`/api/v1/chats/${username}/history`, { limit: historyLimit, offset: 0 }, { title: `读取会话:${contact.displayName}` });
|
|
419
|
+
const selfUsername = safeText(this.service.state.self_username);
|
|
420
|
+
|
|
421
|
+
const profileRequests = [];
|
|
422
|
+
profileRequests.push(this.loadProfile(contact.username));
|
|
423
|
+
profileRequests.push(selfUsername ? this.loadProfile(selfUsername) : Promise.resolve(null));
|
|
424
|
+
|
|
425
|
+
const [historyResult, contactProfileResult, selfProfileResult] = await Promise.allSettled([historyPromise, ...profileRequests]);
|
|
426
|
+
|
|
427
|
+
if (historyResult.status === "fulfilled") {
|
|
428
|
+
this.compose.history = normalizeHistory(historyResult.value?.items, selfUsername, historyLimit);
|
|
429
|
+
this.compose.message = pickLatestIncomingMessage(this.compose.history) || safeText(contact.summary);
|
|
430
|
+
} else {
|
|
431
|
+
this.compose.history = [];
|
|
432
|
+
this.compose.message = safeText(contact.summary);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this.compose.profiles.contact = contactProfileResult.status === "fulfilled" ? contactProfileResult.value : null;
|
|
436
|
+
this.compose.profiles.self = selfProfileResult.status === "fulfilled" ? selfProfileResult.value : null;
|
|
437
|
+
|
|
438
|
+
if (!this.compose.message) {
|
|
439
|
+
this.showToast("没读到最近一句消息,暂时无法生成回复。", "error");
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
this.busy.loadingConversation = false;
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
async loadProfile(username) {
|
|
447
|
+
if (!username) return null;
|
|
448
|
+
const encoded = encodeURIComponent(username);
|
|
449
|
+
const payload = await this.apiGet(`/api/v1/people/${encoded}/profile`, {}, { title: `读取画像:${username}` });
|
|
450
|
+
return normalizeProfile(payload);
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async generateReplies({ reason = "manual" } = {}) {
|
|
454
|
+
if (!this.compose.contact.username || !this.compose.message) {
|
|
455
|
+
this.showToast("没有可生成的会话内容。", "error");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
this.busy.generating = true;
|
|
460
|
+
try {
|
|
461
|
+
const request = buildAiRequest({
|
|
462
|
+
contact: this.compose.contact,
|
|
463
|
+
latestMessage: this.compose.message,
|
|
464
|
+
history: this.compose.history,
|
|
465
|
+
contextMessageCount: this.settings.contextMessageCount,
|
|
466
|
+
replyIntent: this.compose.replyIntent,
|
|
467
|
+
tonePreset: this.settings.tonePreset,
|
|
468
|
+
personaMode: this.settings.personaMode,
|
|
469
|
+
selfProfile: this.compose.profiles.self,
|
|
470
|
+
contactProfile: this.compose.profiles.contact,
|
|
471
|
+
nonce: this.compose.generationNonce,
|
|
472
|
+
reason
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const response = await this.kit.ai.request(request);
|
|
476
|
+
const parsed = extractAiJson(response);
|
|
477
|
+
const replies = normalizeReplyList(parsed?.replies ?? parsed?.candidates);
|
|
478
|
+
|
|
479
|
+
if (!replies.length) {
|
|
480
|
+
this.compose.replies = buildFallbackReplies({
|
|
481
|
+
latestMessage: this.compose.message,
|
|
482
|
+
tonePreset: this.settings.tonePreset,
|
|
483
|
+
personaMode: this.settings.personaMode,
|
|
484
|
+
count: REPLY_COUNT
|
|
485
|
+
});
|
|
486
|
+
this.showToast("AI 返回不可解析,已降级为本地候选。", "error");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.compose.replies = replies.slice(0, REPLY_COUNT);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
this.compose.replies = buildFallbackReplies({
|
|
493
|
+
latestMessage: this.compose.message,
|
|
494
|
+
tonePreset: this.settings.tonePreset,
|
|
495
|
+
personaMode: this.settings.personaMode,
|
|
496
|
+
count: REPLY_COUNT
|
|
497
|
+
});
|
|
498
|
+
this.showToast(`生成失败:${formatError(error)}`, "error");
|
|
499
|
+
} finally {
|
|
500
|
+
this.busy.generating = false;
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
async changeBatch() {
|
|
505
|
+
this.compose.generationNonce += 1;
|
|
506
|
+
await this.generateReplies({ reason: "reroll" });
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
toggleAdvanced() {
|
|
510
|
+
this.compose.advancedOpen = !this.compose.advancedOpen;
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
async selectTonePreset(nextPreset) {
|
|
514
|
+
if (this.settings.tonePreset === nextPreset) return;
|
|
515
|
+
this.settings.tonePreset = nextPreset;
|
|
516
|
+
await this.persistState();
|
|
517
|
+
this.queueAutoGenerate();
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
async selectPersonaMode(nextMode) {
|
|
521
|
+
if (this.settings.personaMode === nextMode) return;
|
|
522
|
+
this.settings.personaMode = nextMode;
|
|
523
|
+
await this.persistState();
|
|
524
|
+
this.queueAutoGenerate();
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
async increaseContextMessageCount() {
|
|
528
|
+
await this.updateContextMessageCount(this.settings.contextMessageCount + CONTEXT_MESSAGE_COUNT_STEP);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
async decreaseContextMessageCount() {
|
|
532
|
+
await this.updateContextMessageCount(this.settings.contextMessageCount - CONTEXT_MESSAGE_COUNT_STEP);
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
async updateContextMessageCount(nextCount) {
|
|
536
|
+
const normalized = normalizeContextMessageCount(nextCount);
|
|
537
|
+
if (normalized === this.settings.contextMessageCount) return;
|
|
538
|
+
this.settings.contextMessageCount = normalized;
|
|
539
|
+
await this.persistState();
|
|
540
|
+
|
|
541
|
+
if (!this.compose.contact.username) return;
|
|
542
|
+
|
|
543
|
+
this.compose.replies = [];
|
|
544
|
+
this.compose.lastInsertedReplyId = "";
|
|
545
|
+
this.compose.generationNonce += 1;
|
|
546
|
+
await this.loadConversation(this.compose.contact);
|
|
547
|
+
if (this.compose.message) {
|
|
548
|
+
await this.generateReplies({ reason: "history-window-change" });
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
onReplyIntentInput(value) {
|
|
553
|
+
this.compose.replyIntent = typeof value === "string" ? value : "";
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
async applyReplyIntent() {
|
|
557
|
+
this.compose.replyIntent = safeText(this.compose.replyIntent);
|
|
558
|
+
if (!this.compose.contact.username || !this.compose.message) return;
|
|
559
|
+
this.compose.replies = [];
|
|
560
|
+
this.compose.lastInsertedReplyId = "";
|
|
561
|
+
this.compose.generationNonce += 1;
|
|
562
|
+
await this.generateReplies({ reason: "reply-intent-change" });
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
queueAutoGenerate() {
|
|
566
|
+
if (this.autoGenerateTimer) {
|
|
567
|
+
clearTimeout(this.autoGenerateTimer);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!this.compose.contact.username || !this.compose.message) return;
|
|
571
|
+
|
|
572
|
+
this.autoGenerateTimer = setTimeout(() => {
|
|
573
|
+
this.compose.generationNonce += 1;
|
|
574
|
+
this.generateReplies({ reason: "config-change" }).catch(() => {});
|
|
575
|
+
}, 180);
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
clearReplies() {
|
|
579
|
+
this.compose.replies = [];
|
|
580
|
+
this.compose.message = "";
|
|
581
|
+
this.compose.history = [];
|
|
582
|
+
this.compose.profiles = { self: null, contact: null };
|
|
583
|
+
this.compose.lastInsertedReplyId = "";
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
async applyReply(reply) {
|
|
587
|
+
if (!this.kit.hasPermission("input.insert")) {
|
|
588
|
+
this.showToast("缺少 input.insert 权限。", "error");
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const text = safeText(reply?.text);
|
|
593
|
+
if (!text) return;
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
this.kit.input.insert(text, { candidateId: safeText(reply?.id) || undefined });
|
|
597
|
+
this.compose.lastInsertedReplyId = reply.id;
|
|
598
|
+
this.showToast("已写入输入框", "success");
|
|
599
|
+
} catch (error) {
|
|
600
|
+
this.showToast(`写入失败:${formatError(error)}`, "error");
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
resolveAvatarSrc(contact) {
|
|
605
|
+
const avatar = safeText(contact?.avatarUrl);
|
|
606
|
+
if (!avatar) return "";
|
|
607
|
+
try {
|
|
608
|
+
const resolved = new URL(avatar, `${normalizeBaseUrl(this.settings.baseUrl)}/`).toString();
|
|
609
|
+
return proxyExternalResourceUrl(resolved);
|
|
610
|
+
} catch (_) {
|
|
611
|
+
return "";
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
avatarFallback(contact) {
|
|
616
|
+
if (contact?.isGroup) return "群";
|
|
617
|
+
const displayName = safeText(contact?.displayName);
|
|
618
|
+
return displayName ? displayName.slice(0, 1) : "人";
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
isAvatarBroken(username) {
|
|
622
|
+
return this.avatarErrorMap[safeText(username)] === true;
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
markAvatarBroken(username) {
|
|
626
|
+
const key = safeText(username);
|
|
627
|
+
if (!key) return;
|
|
628
|
+
this.avatarErrorMap = {
|
|
629
|
+
...this.avatarErrorMap,
|
|
630
|
+
[key]: true
|
|
631
|
+
};
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
showToast(text, kind = "info") {
|
|
635
|
+
this.toast.text = safeText(text);
|
|
636
|
+
this.toast.kind = kind;
|
|
637
|
+
if (this.toastTimer) {
|
|
638
|
+
clearTimeout(this.toastTimer);
|
|
639
|
+
}
|
|
640
|
+
if (this.toast.text) {
|
|
641
|
+
this.toastTimer = setTimeout(() => {
|
|
642
|
+
this.toast.text = "";
|
|
643
|
+
}, 2200);
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
async apiGet(path, query = {}, task = null) {
|
|
648
|
+
const url = buildUrl(this.settings.baseUrl, path, query);
|
|
649
|
+
const response = await this.kit.fetch(url, {
|
|
650
|
+
method: "GET",
|
|
651
|
+
headers: buildHeaders(this.settings.apiToken),
|
|
652
|
+
task: task ? { title: safeText(task.title) } : undefined
|
|
653
|
+
});
|
|
654
|
+
return parseJsonResponse(response);
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
async apiPost(path, body = {}, task = null) {
|
|
658
|
+
const url = buildUrl(this.settings.baseUrl, path);
|
|
659
|
+
const response = await this.kit.fetch(url, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers: {
|
|
662
|
+
"Content-Type": "application/json",
|
|
663
|
+
...buildHeaders(this.settings.apiToken)
|
|
664
|
+
},
|
|
665
|
+
body: JSON.stringify(body ?? {}),
|
|
666
|
+
task: task ? { title: safeText(task.title) } : undefined
|
|
667
|
+
});
|
|
668
|
+
return parseJsonResponse(response);
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
PetiteVue.createApp(vm).mount("#app");
|
|
673
|
+
vm.init().catch(() => {});
|
|
674
|
+
|
|
675
|
+
function buildAiRequest({
|
|
676
|
+
contact,
|
|
677
|
+
latestMessage,
|
|
678
|
+
history,
|
|
679
|
+
contextMessageCount,
|
|
680
|
+
replyIntent,
|
|
681
|
+
tonePreset,
|
|
682
|
+
personaMode,
|
|
683
|
+
selfProfile,
|
|
684
|
+
contactProfile,
|
|
685
|
+
nonce,
|
|
686
|
+
reason
|
|
687
|
+
}) {
|
|
688
|
+
const toneLabel = tonePresetLabel(tonePreset);
|
|
689
|
+
const historyWindow = normalizeContextMessageCount(contextMessageCount);
|
|
690
|
+
const historyText = formatHistory(history, historyWindow);
|
|
691
|
+
const replyIntentText = safeText(replyIntent);
|
|
692
|
+
const selfProfileText = personaMode === "merge-self" ? summarizeProfile(selfProfile, "我的画像") : "";
|
|
693
|
+
const contactProfileText = summarizeProfile(contactProfile, "对方画像");
|
|
694
|
+
|
|
695
|
+
const systemPrompt = [
|
|
696
|
+
"你是一个微信聊天回复助手,只负责生成可直接发送的中文回复。",
|
|
697
|
+
"输出必须是 JSON,不要输出 Markdown,不要输出解释。",
|
|
698
|
+
'{"replies":[{"text":"...","confidence":"high|medium|low"}]}',
|
|
699
|
+
`固定生成 ${REPLY_COUNT} 条回复。`,
|
|
700
|
+
`回复风格:${toneLabel}。`,
|
|
701
|
+
`必须综合最近 ${historyWindow} 条聊天消息来判断语境,不要只盯着最后一句。`,
|
|
702
|
+
replyIntentText ? "如果给了“本次回复方向”,优先满足该目标,再保证自然可直接发送。" : "",
|
|
703
|
+
"- 每条回复 1~2 句,优先短句。",
|
|
704
|
+
"- 语气自然,不要生硬,不要模板腔,不要过度客套。",
|
|
705
|
+
"- 不要重复低质量口水词。",
|
|
706
|
+
"- 如果对方消息信息不足,可给出一句自然的澄清回复。",
|
|
707
|
+
"- 不要输出序号、前缀标签、括号解释。"
|
|
708
|
+
].join("\n");
|
|
709
|
+
|
|
710
|
+
const userPayload = [
|
|
711
|
+
`当前对象:${contact.displayName || contact.username}${contact.isGroup ? "(群聊)" : ""}`,
|
|
712
|
+
`最近一句对方消息:${latestMessage}`,
|
|
713
|
+
historyText ? `最近 ${historyWindow} 条聊天记录(越近越重要):\n${historyText}` : "",
|
|
714
|
+
replyIntentText ? `本次回复方向:${replyIntentText}` : "",
|
|
715
|
+
contactProfileText,
|
|
716
|
+
selfProfileText,
|
|
717
|
+
personaMode === "merge-self" ? "要求:尽量融合我的表达习惯与稳定人设。" : "要求:不要依赖任何画像,只根据会话生成。",
|
|
718
|
+
`换批次随机因子:${nonce || 0}`,
|
|
719
|
+
`触发原因:${reason}`
|
|
720
|
+
]
|
|
721
|
+
.filter(Boolean)
|
|
722
|
+
.join("\n\n");
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
task: { title: `给 ${contact.displayName || contact.username} 生成微信回复(近${historyWindow}条${replyIntentText ? ` / ${truncateLabel(replyIntentText, 10)}` : ""})` },
|
|
726
|
+
route: { kind: "host-shared" },
|
|
727
|
+
temperature: 0.72,
|
|
728
|
+
maxTokens: 320,
|
|
729
|
+
systemPrompt,
|
|
730
|
+
messages: [{ role: "user", content: userPayload }]
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function buildFallbackReplies({ latestMessage, tonePreset, count }) {
|
|
735
|
+
const snippet = safeText(latestMessage).slice(0, 14) || "这件事";
|
|
736
|
+
const templates = {
|
|
737
|
+
work: [
|
|
738
|
+
`收到,关于“${snippet}”,我先确认一下,稍后给您明确回复。`,
|
|
739
|
+
`好的,这件事我马上处理,处理完第一时间同步您。`,
|
|
740
|
+
`明白,我先核对下细节,尽快给您一个确定答复。`
|
|
741
|
+
],
|
|
742
|
+
eq: [
|
|
743
|
+
`收到啦,我已经看到“${snippet}”这件事了,我先处理一下,稍后认真回您。`,
|
|
744
|
+
`好的,我明白您的意思,我这边马上跟进,有结果第一时间告诉您。`,
|
|
745
|
+
`我先帮您确认一下,确认清楚后尽快给您答复。`
|
|
746
|
+
],
|
|
747
|
+
daily: [
|
|
748
|
+
`看到了,我先处理下“${snippet}”,等会儿回你。`,
|
|
749
|
+
`好,我先看一下,弄清楚了马上跟你说。`,
|
|
750
|
+
`行,我这边先确认一下,稍后给你回。`
|
|
751
|
+
]
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const pool = templates[tonePreset] || templates.work;
|
|
755
|
+
return pool.slice(0, Math.max(1, count)).map((text, index) => ({
|
|
756
|
+
id: `fallback-${index + 1}`,
|
|
757
|
+
text,
|
|
758
|
+
confidence: "low"
|
|
759
|
+
}));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function normalizeSettings(raw) {
|
|
763
|
+
const parsed = parsePossibleJson(raw);
|
|
764
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
baseUrl: normalizeBaseUrl(parsed.baseUrl || DEFAULT_BASE_URL),
|
|
768
|
+
apiToken: safeText(parsed.apiToken),
|
|
769
|
+
tonePreset: ["work", "eq", "daily"].includes(parsed.tonePreset) ? parsed.tonePreset : "work",
|
|
770
|
+
personaMode: ["merge-self", "none"].includes(parsed.personaMode) ? parsed.personaMode : "merge-self",
|
|
771
|
+
contextMessageCount: normalizeContextMessageCount(parsed.contextMessageCount)
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function normalizeContactList(raw) {
|
|
776
|
+
const parsed = parsePossibleJson(raw);
|
|
777
|
+
const items = Array.isArray(parsed)
|
|
778
|
+
? parsed
|
|
779
|
+
: Array.isArray(raw)
|
|
780
|
+
? raw
|
|
781
|
+
: Array.isArray(parsed?.items)
|
|
782
|
+
? parsed.items
|
|
783
|
+
: [];
|
|
784
|
+
return items.map(normalizeContact).filter((item) => item.username);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function normalizeContact(raw) {
|
|
788
|
+
const username = safeText(raw?.username);
|
|
789
|
+
const displayName =
|
|
790
|
+
safeText(raw?.display_name) ||
|
|
791
|
+
safeText(raw?.displayName) ||
|
|
792
|
+
safeText(raw?.remark) ||
|
|
793
|
+
safeText(raw?.nick_name) ||
|
|
794
|
+
username;
|
|
795
|
+
|
|
796
|
+
const unread = toInt(raw?.unread);
|
|
797
|
+
return {
|
|
798
|
+
username,
|
|
799
|
+
displayName,
|
|
800
|
+
remark: safeText(raw?.remark),
|
|
801
|
+
summary: safeText(raw?.summary),
|
|
802
|
+
avatarUrl: safeText(raw?.avatar_url) || safeText(raw?.avatarUrl),
|
|
803
|
+
isGroup: raw?.is_group === true || raw?.isGroup === true,
|
|
804
|
+
unread,
|
|
805
|
+
unreadDisplay: unread > 99 ? "99+" : unread > 0 ? String(unread) : "",
|
|
806
|
+
lastTimestamp: toInt(raw?.last_timestamp ?? raw?.lastTimestamp ?? raw?.last_access_ts ?? raw?.lastAccessTs),
|
|
807
|
+
source: safeText(raw?.source)
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function mergeContacts(primary, secondary) {
|
|
812
|
+
const map = new Map();
|
|
813
|
+
for (const item of [...(primary || []), ...(secondary || [])]) {
|
|
814
|
+
const contact = normalizeContact(item);
|
|
815
|
+
if (!contact.username) continue;
|
|
816
|
+
const existing = map.get(contact.username);
|
|
817
|
+
if (!existing) {
|
|
818
|
+
map.set(contact.username, contact);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
map.set(contact.username, {
|
|
822
|
+
...existing,
|
|
823
|
+
displayName: existing.displayName || contact.displayName,
|
|
824
|
+
remark: existing.remark || contact.remark,
|
|
825
|
+
summary: existing.summary || contact.summary,
|
|
826
|
+
avatarUrl: existing.avatarUrl || contact.avatarUrl,
|
|
827
|
+
isGroup: existing.isGroup || contact.isGroup,
|
|
828
|
+
unread: existing.unread || contact.unread,
|
|
829
|
+
unreadDisplay: existing.unreadDisplay || contact.unreadDisplay,
|
|
830
|
+
lastTimestamp: existing.lastTimestamp || contact.lastTimestamp,
|
|
831
|
+
source: existing.source || contact.source
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return Array.from(map.values());
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function normalizeHistory(items, selfUsername, limit = MAX_CONTEXT_MESSAGE_COUNT * 2) {
|
|
838
|
+
const list = [];
|
|
839
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
840
|
+
const text = safeText(item?.text || item?.raw || item?.content);
|
|
841
|
+
if (!text) continue;
|
|
842
|
+
|
|
843
|
+
const isSend = item?.is_send === true || item?.is_send === 1 || safeText(item?.direction) === "out";
|
|
844
|
+
const senderUsername = safeText(item?.sender_username);
|
|
845
|
+
const role = isSend || (selfUsername && senderUsername === selfUsername) ? "me" : "them";
|
|
846
|
+
|
|
847
|
+
list.push({
|
|
848
|
+
key: safeText(item?.local_id) || `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
849
|
+
role,
|
|
850
|
+
text
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
return list.slice(-Math.max(1, toInt(limit)));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function pickLatestIncomingMessage(history) {
|
|
857
|
+
const incoming = [...(Array.isArray(history) ? history : [])].reverse().find((item) => item.role === "them" && safeText(item.text));
|
|
858
|
+
if (incoming) return safeText(incoming.text);
|
|
859
|
+
const fallback = [...(Array.isArray(history) ? history : [])].reverse().find((item) => safeText(item.text));
|
|
860
|
+
return fallback ? safeText(fallback.text) : "";
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function formatHistory(history, limit = DEFAULT_CONTEXT_MESSAGE_COUNT) {
|
|
864
|
+
if (!Array.isArray(history) || !history.length) return "";
|
|
865
|
+
const normalizedLimit = normalizeContextMessageCount(limit);
|
|
866
|
+
return history
|
|
867
|
+
.slice(-normalizedLimit)
|
|
868
|
+
.map((item) => `${item.role === "me" ? "我" : "对方"}:${safeText(item.text)}`)
|
|
869
|
+
.join("\n");
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function normalizeContextMessageCount(value) {
|
|
873
|
+
const parsed = Number(value);
|
|
874
|
+
if (!Number.isFinite(parsed)) {
|
|
875
|
+
return DEFAULT_CONTEXT_MESSAGE_COUNT;
|
|
876
|
+
}
|
|
877
|
+
const rounded = Math.round(parsed / CONTEXT_MESSAGE_COUNT_STEP) * CONTEXT_MESSAGE_COUNT_STEP;
|
|
878
|
+
return Math.min(MAX_CONTEXT_MESSAGE_COUNT, Math.max(MIN_CONTEXT_MESSAGE_COUNT, rounded));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function computeHistoryFetchLimit(value) {
|
|
882
|
+
const target = normalizeContextMessageCount(value);
|
|
883
|
+
return Math.min(80, Math.max(24, target * 2));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function normalizeSearchQuery(value) {
|
|
887
|
+
return typeof value === "string" ? value.trim() : "";
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function matchesSearchQuery(contact, query) {
|
|
891
|
+
const needle = normalizeSearchQuery(query).toLowerCase();
|
|
892
|
+
if (!needle) return false;
|
|
893
|
+
const haystacks = [
|
|
894
|
+
contact?.displayName,
|
|
895
|
+
contact?.remark,
|
|
896
|
+
contact?.username,
|
|
897
|
+
contact?.summary
|
|
898
|
+
]
|
|
899
|
+
.map((item) => safeText(item).toLowerCase())
|
|
900
|
+
.filter(Boolean);
|
|
901
|
+
return haystacks.some((item) => item.includes(needle));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function truncateLabel(value, maxChars = 10) {
|
|
905
|
+
const text = safeText(value);
|
|
906
|
+
if (!text || text.length <= maxChars) return text;
|
|
907
|
+
return `${text.slice(0, maxChars)}…`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function normalizeProfile(raw) {
|
|
911
|
+
const payload = parsePossibleJson(raw);
|
|
912
|
+
if (!payload || typeof payload !== "object") return null;
|
|
913
|
+
|
|
914
|
+
const profile = payload.profile && typeof payload.profile === "object" ? payload.profile : payload;
|
|
915
|
+
const tags = Array.isArray(profile.tags) ? profile.tags.map(safeText).filter(Boolean) : [];
|
|
916
|
+
const notes = Array.isArray(profile.notes)
|
|
917
|
+
? profile.notes.map(safeText).filter(Boolean)
|
|
918
|
+
: safeText(profile.notes)
|
|
919
|
+
? [safeText(profile.notes)]
|
|
920
|
+
: [];
|
|
921
|
+
const policy = safeText(profile.auto_reply_policy);
|
|
922
|
+
|
|
923
|
+
if (!tags.length && !notes.length && !policy) {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return { tags, notes, policy };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function summarizeProfile(profile, title) {
|
|
931
|
+
if (!profile) return "";
|
|
932
|
+
const lines = [];
|
|
933
|
+
if (profile.tags?.length) {
|
|
934
|
+
lines.push(`标签:${profile.tags.join("、")}`);
|
|
935
|
+
}
|
|
936
|
+
if (profile.notes?.length) {
|
|
937
|
+
lines.push(`备注:${profile.notes.slice(0, 3).join(";")}`);
|
|
938
|
+
}
|
|
939
|
+
if (profile.policy) {
|
|
940
|
+
lines.push(`偏好:${profile.policy}`);
|
|
941
|
+
}
|
|
942
|
+
return lines.length ? `${title}:\n${lines.join("\n")}` : "";
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function extractAiJson(response) {
|
|
946
|
+
const output = response?.output ?? response ?? {};
|
|
947
|
+
if (output?.json && typeof output.json === "object") return output.json;
|
|
948
|
+
if (output?.structured && typeof output.structured === "object") return output.structured;
|
|
949
|
+
return parsePossibleJson(output?.text);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function normalizeReplyList(raw) {
|
|
953
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
954
|
+
return list
|
|
955
|
+
.map((item, index) => ({
|
|
956
|
+
id: safeText(item?.id) || `reply-${index + 1}`,
|
|
957
|
+
text: safeText(item?.text),
|
|
958
|
+
confidence: safeText(item?.confidence) || "medium"
|
|
959
|
+
}))
|
|
960
|
+
.filter((item) => item.text);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function parseJsonResponse(raw) {
|
|
964
|
+
if (isNetworkFetchResponse(raw)) {
|
|
965
|
+
if (!raw.ok) {
|
|
966
|
+
const snippet = safeText(raw.body).slice(0, 140);
|
|
967
|
+
throw new Error(`HTTP ${raw.status}${snippet ? `: ${snippet}` : ""}`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const parsed = parsePossibleJson(raw.body);
|
|
971
|
+
if (parsed === null) {
|
|
972
|
+
throw new Error("服务返回的不是 JSON。");
|
|
973
|
+
}
|
|
974
|
+
return parsed;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const parsed = parsePossibleJson(raw);
|
|
978
|
+
return parsed === null ? raw : parsed;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function isNetworkFetchResponse(value) {
|
|
982
|
+
return !!value && typeof value === "object" && typeof value.status === "number" && typeof value.ok === "boolean" && "body" in value;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function buildUrl(baseUrl, path, query = {}) {
|
|
986
|
+
const normalizedBaseUrl = `${normalizeBaseUrl(baseUrl)}/`;
|
|
987
|
+
const url = new URL(path.replace(/^\//, ""), normalizedBaseUrl);
|
|
988
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
989
|
+
if (value === undefined || value === null || safeText(String(value)) === "") continue;
|
|
990
|
+
url.searchParams.set(key, String(value));
|
|
991
|
+
}
|
|
992
|
+
return url.toString();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function buildHeaders(apiToken) {
|
|
996
|
+
const token = safeText(apiToken);
|
|
997
|
+
if (!token) return {};
|
|
998
|
+
return {
|
|
999
|
+
Authorization: token.startsWith("Bearer ") ? token : `Bearer ${token}`
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function normalizeBaseUrl(url) {
|
|
1004
|
+
const raw = safeText(url) || DEFAULT_BASE_URL;
|
|
1005
|
+
return raw.replace(/\/+$/, "");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function proxyExternalResourceUrl(url) {
|
|
1009
|
+
const raw = safeText(url);
|
|
1010
|
+
if (!raw) return "";
|
|
1011
|
+
try {
|
|
1012
|
+
const parsed = new URL(raw);
|
|
1013
|
+
if (parsed.protocol !== "http:") {
|
|
1014
|
+
return parsed.toString();
|
|
1015
|
+
}
|
|
1016
|
+
const token = toBase64Url(parsed.toString());
|
|
1017
|
+
return token ? `${EXTERNAL_RESOURCE_PROXY_BASE}${token}` : parsed.toString();
|
|
1018
|
+
} catch (_) {
|
|
1019
|
+
return "";
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function toBase64Url(value) {
|
|
1024
|
+
const raw = safeText(value);
|
|
1025
|
+
if (!raw) return "";
|
|
1026
|
+
try {
|
|
1027
|
+
return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
1028
|
+
} catch (_) {
|
|
1029
|
+
return "";
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function emptyContact() {
|
|
1034
|
+
return {
|
|
1035
|
+
username: "",
|
|
1036
|
+
displayName: "",
|
|
1037
|
+
remark: "",
|
|
1038
|
+
summary: "",
|
|
1039
|
+
avatarUrl: "",
|
|
1040
|
+
isGroup: false,
|
|
1041
|
+
unread: 0,
|
|
1042
|
+
unreadDisplay: "",
|
|
1043
|
+
lastTimestamp: 0,
|
|
1044
|
+
source: ""
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function tonePresetLabel(tonePreset) {
|
|
1049
|
+
if (tonePreset === "eq") return "高情商、会照顾对方感受";
|
|
1050
|
+
if (tonePreset === "daily") return "日常自然、轻松直接";
|
|
1051
|
+
return "工作专业、简洁稳妥";
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function parsePossibleJson(value) {
|
|
1055
|
+
if (value && typeof value === "object") return value;
|
|
1056
|
+
const text = safeText(typeof value === "string" ? value : "");
|
|
1057
|
+
if (!text) return null;
|
|
1058
|
+
try {
|
|
1059
|
+
return JSON.parse(text);
|
|
1060
|
+
} catch (_) {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function safeText(value) {
|
|
1066
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function toInt(value) {
|
|
1070
|
+
const number = Number(value);
|
|
1071
|
+
return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function formatError(error) {
|
|
1075
|
+
if (!error) return "未知错误";
|
|
1076
|
+
if (typeof error === "string") return error;
|
|
1077
|
+
return safeText(error.message) || String(error);
|
|
1078
|
+
}
|
|
1079
|
+
})();
|
|
1080
|
+
|