dsh-lark-agent 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/bot-service.js +4 -2
- package/dist/bridge.d.ts +25 -0
- package/dist/bridge.js +312 -55
- package/dist/card-actions.js +3 -1
- package/dist/dsh-runtime.js +2 -0
- package/dist/lark-cli.d.ts +13 -1
- package/dist/lark-cli.js +63 -1
- package/dist/onboarding/host.js +5 -17
- package/dist/onboarding/model-check.d.ts +5 -0
- package/dist/onboarding/model-check.js +51 -0
- package/dist/replies.d.ts +16 -0
- package/dist/replies.js +37 -11
- package/dist/session-controls.d.ts +34 -0
- package/dist/session-controls.js +66 -0
- package/dist/session-store.d.ts +2 -0
- package/dist/session-store.js +12 -7
- package/dist/topic-cards.d.ts +3 -0
- package/dist/topic-cards.js +8 -0
- package/dist/topic-store.d.ts +40 -0
- package/dist/topic-store.js +192 -0
- package/package.json +1 -1
package/dist/lark-cli.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReplyPayload } from './replies.js';
|
|
2
|
+
import { type TopicReceipt } from './topic-store.js';
|
|
2
3
|
export type LarkEventKey = 'im.message.receive_v1' | 'card.action.trigger' | 'application.bot.menu_v6';
|
|
3
4
|
export interface LarkCliOptions {
|
|
4
5
|
executable?: string;
|
|
@@ -46,6 +47,17 @@ export declare class LarkCli {
|
|
|
46
47
|
sendUser(openId: string, text: string, options: {
|
|
47
48
|
idempotencyKey: string;
|
|
48
49
|
}): Promise<void>;
|
|
50
|
+
/** Open a native topic and keep its raw receipt; the reply shortcut drops thread_id. */
|
|
51
|
+
openTopic(target: {
|
|
52
|
+
chatId: string;
|
|
53
|
+
messageId?: string;
|
|
54
|
+
}, title: string, options: {
|
|
55
|
+
idempotencyKey: string;
|
|
56
|
+
chatType?: 'p2p' | 'group';
|
|
57
|
+
textOnly?: boolean;
|
|
58
|
+
}): Promise<TopicReceipt>;
|
|
59
|
+
/** Optional read enrichment. A missing link must never cause another topic creation. */
|
|
60
|
+
topicLink(messageId: string): Promise<string | undefined>;
|
|
49
61
|
/** Create a short-lived loading card; its receipt stays inside the bridge. */
|
|
50
62
|
createFeedback(target: {
|
|
51
63
|
openId: string;
|
package/dist/lark-cli.js
CHANGED
|
@@ -2,6 +2,9 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { basename, dirname, isAbsolute } from 'node:path';
|
|
4
4
|
import { StringDecoder } from 'node:string_decoder';
|
|
5
|
+
import { renderPanel } from './replies.js';
|
|
6
|
+
import { topicLink } from './topic-store.js';
|
|
7
|
+
import { topicEntryPanel } from './topic-cards.js';
|
|
5
8
|
const DEFAULT_EVENT_KEY = 'im.message.receive_v1';
|
|
6
9
|
const MAX_OUTPUT_BYTES = 1_048_576;
|
|
7
10
|
const MAX_STDERR_LINE_BYTES = 65_536;
|
|
@@ -326,6 +329,65 @@ export class LarkCli {
|
|
|
326
329
|
sendUser(openId, text, options) {
|
|
327
330
|
return this.sendRichUser(openId, { kind: 'text', text }, options);
|
|
328
331
|
}
|
|
332
|
+
/** Open a native topic and keep its raw receipt; the reply shortcut drops thread_id. */
|
|
333
|
+
async openTopic(target, title, options) {
|
|
334
|
+
if (!target || typeof target.chatId !== 'string' || !/^oc_[A-Za-z0-9_-]{1,128}$/.test(target.chatId)
|
|
335
|
+
|| (target.messageId !== undefined && !/^om_[A-Za-z0-9_-]{1,256}$/.test(target.messageId))
|
|
336
|
+
|| typeof title !== 'string' || !title.trim() || Array.from(title).length > 80 || /[\u0000-\u001f\u007f-\u009f]/.test(title)
|
|
337
|
+
|| !/^[A-Za-z0-9._:-]{1,45}$/.test(options?.idempotencyKey)
|
|
338
|
+
|| (options.chatType !== undefined && options.chatType !== 'p2p' && options.chatType !== 'group')
|
|
339
|
+
|| (options.textOnly !== undefined && typeof options.textOnly !== 'boolean'))
|
|
340
|
+
throw new Error('Invalid Lark topic target.');
|
|
341
|
+
const text = `话题:${title}\n请在这个话题里继续。${options.chatType === 'group' ? '群聊中每条消息仍需 @机器人。' : ''}`;
|
|
342
|
+
const request = async (path, data, params) => {
|
|
343
|
+
const result = await this.executeJson(['api', 'POST', path, ...(params ? ['--params', params] : []),
|
|
344
|
+
'--data', '-', '--as', 'bot', '--format', 'json'], JSON.stringify(data), 'reply', this.commandTimeoutMs);
|
|
345
|
+
this.confirmFeedback(result);
|
|
346
|
+
const receipt = result.data;
|
|
347
|
+
if (!receipt || typeof receipt.message_id !== 'string' || !/^om_[A-Za-z0-9_-]{1,256}$/.test(receipt.message_id)
|
|
348
|
+
|| receipt.chat_id !== target.chatId)
|
|
349
|
+
throw new Error('Lark topic creation was not confirmed; delivery is unknown.');
|
|
350
|
+
return receipt;
|
|
351
|
+
};
|
|
352
|
+
let anchor = target.messageId;
|
|
353
|
+
let receipt;
|
|
354
|
+
if (!anchor) {
|
|
355
|
+
// Menu events and replies inside another topic cannot be used as a new root.
|
|
356
|
+
// Confirm the root before replying; never invent a message ID or retry an unknown send.
|
|
357
|
+
const content = options.textOnly ? JSON.stringify({ text }) : this.feedbackContent(renderPanel(topicEntryPanel(title, options.chatType ?? 'p2p')));
|
|
358
|
+
receipt = await request('/open-apis/im/v1/messages', { receive_id: target.chatId, msg_type: options.textOnly ? 'text' : 'interactive',
|
|
359
|
+
content, uuid: `${options.idempotencyKey}:root` }, '{"receive_id_type":"chat_id"}');
|
|
360
|
+
anchor = receipt.message_id;
|
|
361
|
+
}
|
|
362
|
+
if (!receipt?.thread_id)
|
|
363
|
+
receipt = await request(`/open-apis/im/v1/messages/${anchor}/reply`, { msg_type: 'text', content: JSON.stringify({ text: target.messageId ? text : `在这里发送消息继续。${options.chatType === 'group' ? '每条消息仍需 @机器人。' : ''}` }),
|
|
364
|
+
reply_in_thread: true, uuid: `${options.idempotencyKey}:open` });
|
|
365
|
+
if (typeof receipt.thread_id !== 'string' || !/^(?:om|omt)_[A-Za-z0-9_-]{1,256}$/.test(receipt.thread_id)) {
|
|
366
|
+
throw new Error('Lark topic creation did not return a thread ID; delivery is unknown.');
|
|
367
|
+
}
|
|
368
|
+
const messageId = receipt.message_id;
|
|
369
|
+
const link = await this.topicLink(messageId);
|
|
370
|
+
return { threadId: receipt.thread_id, messageId, ...(link ? { link } : {}) };
|
|
371
|
+
}
|
|
372
|
+
/** Optional read enrichment. A missing link must never cause another topic creation. */
|
|
373
|
+
async topicLink(messageId) {
|
|
374
|
+
if (!/^om_[A-Za-z0-9_-]{1,256}$/.test(messageId))
|
|
375
|
+
throw new Error('Invalid Lark topic anchor.');
|
|
376
|
+
try {
|
|
377
|
+
const result = await this.executeJson(['im', '+messages-mget', '--message-ids', messageId, '--no-reactions',
|
|
378
|
+
'--as', 'bot', '--format', 'json'], '', 'read', Math.min(this.commandTimeoutMs, 5_000));
|
|
379
|
+
this.confirmFeedback(result);
|
|
380
|
+
const data = result.data;
|
|
381
|
+
const rows = data?.messages ?? result.messages;
|
|
382
|
+
if (!Array.isArray(rows))
|
|
383
|
+
return;
|
|
384
|
+
const message = rows.find(row => row && typeof row === 'object' && row.message_id === messageId);
|
|
385
|
+
return topicLink(message?.thread_app_link) ?? topicLink(message?.message_app_link);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
329
391
|
/** Create a short-lived loading card; its receipt stays inside the bridge. */
|
|
330
392
|
async createFeedback(target, payload, options) {
|
|
331
393
|
const content = this.feedbackContent(payload);
|
|
@@ -601,7 +663,7 @@ export class LarkCli {
|
|
|
601
663
|
if (this.stopped)
|
|
602
664
|
return Promise.reject(new Error('Lark transport is stopped.'));
|
|
603
665
|
const label = `Lark ${operation} command`;
|
|
604
|
-
const uncertain = operation === 'progress' || operation === 'card' ? 'result is unknown.' : 'delivery is unknown.';
|
|
666
|
+
const uncertain = operation === 'progress' || operation === 'card' || operation === 'read' ? 'result is unknown.' : 'delivery is unknown.';
|
|
605
667
|
return new Promise((resolve, reject) => {
|
|
606
668
|
let child;
|
|
607
669
|
try {
|
package/dist/onboarding/host.js
CHANGED
|
@@ -4,12 +4,13 @@ import { constants, readFileSync } from 'node:fs';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { delimiter, join, resolve } from 'node:path';
|
|
6
6
|
import { createBotService, BotServiceStartError } from '../bot-service.js';
|
|
7
|
-
import { checkRuntimeCompatibility
|
|
7
|
+
import { checkRuntimeCompatibility } from '../dsh-runtime.js';
|
|
8
8
|
import { acquireLock, LockHeldError } from '../lock.js';
|
|
9
9
|
import { ConnectionController, SetupError } from './controller.js';
|
|
10
10
|
import { ConnectionStore } from './store.js';
|
|
11
11
|
import { LarkPlatform } from './lark-platform.js';
|
|
12
12
|
import { npmLatest, UpdateCheck } from './updates.js';
|
|
13
|
+
import { checkConnectionModel } from './model-check.js';
|
|
13
14
|
const endpoints = new Set(['status', 'profiles', 'create', 'use-profile', 'recheck', 'cancel',
|
|
14
15
|
'renew-pairing', 'confirm-owner', 'pause', 'resume', 'disconnect', 'enable-documents', 'cancel-documents']);
|
|
15
16
|
const failure = (message) => ({ ok: false, error: { code: 'internal', message, details: {} } });
|
|
@@ -229,21 +230,7 @@ export async function applyOnboarding(ctx, options = {}) {
|
|
|
229
230
|
const executable = await findLarkExecutable(options.executable);
|
|
230
231
|
const controller = new ConnectionController({ store: new ConnectionStore(directory), platform: new LarkPlatform({ executable }), executable,
|
|
231
232
|
checkRuntime: () => requireRuntime(ctx),
|
|
232
|
-
preflight:
|
|
233
|
-
const policy = config.users[0];
|
|
234
|
-
const timeout = AbortSignal.timeout(45_000);
|
|
235
|
-
const combined = AbortSignal.any([signal, timeout]);
|
|
236
|
-
const session = await new DshRuntime(ctx).create({ ...policy, provider: config.provider, model: config.model });
|
|
237
|
-
try {
|
|
238
|
-
const result = await session.run('这是飞书连接测试,请只回复“连接成功”。', combined);
|
|
239
|
-
if (!(typeof result === 'string' ? result : result.text).trim())
|
|
240
|
-
throw new SetupError('模型没有返回内容,请检查模型设置后重试。');
|
|
241
|
-
await session.checkpoint();
|
|
242
|
-
}
|
|
243
|
-
finally {
|
|
244
|
-
await session.dispose();
|
|
245
|
-
}
|
|
246
|
-
},
|
|
233
|
+
preflight: (config, signal) => checkConnectionModel(ctx, config, signal),
|
|
247
234
|
start: async (config, signal, health, pairedChat) => {
|
|
248
235
|
signal.throwIfAborted();
|
|
249
236
|
const service = await createBotService(ctx, config, health, { directChat: pairedChat });
|
|
@@ -258,7 +245,8 @@ export async function applyOnboarding(ctx, options = {}) {
|
|
|
258
245
|
throw new BotServiceStartError(service);
|
|
259
246
|
throw error;
|
|
260
247
|
}
|
|
261
|
-
},
|
|
248
|
+
},
|
|
249
|
+
});
|
|
262
250
|
const installed = installedPackage();
|
|
263
251
|
const updates = new UpdateCheck(installed?.version, options.latestRelease
|
|
264
252
|
?? (signal => installed ? npmLatest(installed.name, signal) : Promise.resolve(undefined)));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { BotConfig } from '../config.js';
|
|
3
|
+
export declare const CONNECTION_TEST_PROMPT = "\u8FD9\u662F\u98DE\u4E66\u8FDE\u63A5\u6D4B\u8BD5\uFF0C\u8BF7\u53EA\u56DE\u590D\u201C\u8FDE\u63A5\u6210\u529F\u201D\u3002";
|
|
4
|
+
/** One model call, without an Agent or a durable conversation to clean up. */
|
|
5
|
+
export declare function checkConnectionModel(ctx: Context, config: Pick<BotConfig, 'provider' | 'model'>, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { SetupError } from './controller.js';
|
|
3
|
+
export const CONNECTION_TEST_PROMPT = '这是飞书连接测试,请只回复“连接成功”。';
|
|
4
|
+
/** One model call, without an Agent or a durable conversation to clean up. */
|
|
5
|
+
export async function checkConnectionModel(ctx, config, signal) {
|
|
6
|
+
const timeout = AbortSignal.timeout(45_000);
|
|
7
|
+
const combined = AbortSignal.any([signal, timeout]);
|
|
8
|
+
try {
|
|
9
|
+
combined.throwIfAborted();
|
|
10
|
+
const selection = config.provider && config.model ? { provider: config.provider, model: config.model }
|
|
11
|
+
: ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
12
|
+
const llm = ctx.get('llm');
|
|
13
|
+
if (!selection?.provider || !selection.model || typeof llm?.stream !== 'function') {
|
|
14
|
+
throw new SetupError('请先在 dsh 的模型设置中选择可用模型,然后回来继续连接飞书。');
|
|
15
|
+
}
|
|
16
|
+
let text = false, finished = false;
|
|
17
|
+
for await (const chunk of llm.stream({
|
|
18
|
+
provider: selection.provider, model: selection.model,
|
|
19
|
+
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }),
|
|
20
|
+
messages: [createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: CONNECTION_TEST_PROMPT }] })],
|
|
21
|
+
tools: [], signal: combined,
|
|
22
|
+
})) {
|
|
23
|
+
combined.throwIfAborted();
|
|
24
|
+
if (chunk.type === 'text-delta')
|
|
25
|
+
text ||= Boolean(chunk.text.trim());
|
|
26
|
+
if (chunk.type === 'block-end' && chunk.block.type === 'text')
|
|
27
|
+
text ||= Boolean(chunk.block.text.trim());
|
|
28
|
+
if (chunk.type === 'finish') {
|
|
29
|
+
if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted' || chunk.reason.kind === 'tool-calls') {
|
|
30
|
+
throw new SetupError('模型连接测试失败,请检查模型设置后重试。');
|
|
31
|
+
}
|
|
32
|
+
finished = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
combined.throwIfAborted();
|
|
36
|
+
if (!finished)
|
|
37
|
+
throw new SetupError('模型连接测试未完成,请检查模型设置后重试。');
|
|
38
|
+
if (!text)
|
|
39
|
+
throw new SetupError('模型没有返回内容,请检查模型设置后重试。');
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (signal.aborted)
|
|
43
|
+
throw new SetupError('连接已取消。');
|
|
44
|
+
if (timeout.aborted)
|
|
45
|
+
throw new SetupError('模型连接测试超时,请检查模型设置后重试。');
|
|
46
|
+
if (error instanceof SetupError)
|
|
47
|
+
throw error;
|
|
48
|
+
throw new SetupError('模型连接测试失败,请检查模型设置后重试。');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=model-check.js.map
|
package/dist/replies.d.ts
CHANGED
|
@@ -44,6 +44,22 @@ export interface ReplyPanel {
|
|
|
44
44
|
}[];
|
|
45
45
|
actions?: ReplyAction[];
|
|
46
46
|
actionColumns?: 1 | 2 | 3;
|
|
47
|
+
/** Trusted catalog controls; option values and callbacks are supplied by the application. */
|
|
48
|
+
select?: {
|
|
49
|
+
placeholder: string;
|
|
50
|
+
options: {
|
|
51
|
+
label: string;
|
|
52
|
+
value: string;
|
|
53
|
+
}[];
|
|
54
|
+
value: Record<string, string>;
|
|
55
|
+
initialOption?: string;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
};
|
|
58
|
+
search?: {
|
|
59
|
+
query: string;
|
|
60
|
+
value: Record<string, string>;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
};
|
|
47
63
|
note?: string;
|
|
48
64
|
}
|
|
49
65
|
/**
|
package/dist/replies.js
CHANGED
|
@@ -32,13 +32,21 @@ function externalUrl(value) {
|
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
function escapeProse(text) {
|
|
36
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
37
|
+
}
|
|
38
|
+
/** Lark takes Markdown hrefs literally: HTML escaping '&' changes their query keys. */
|
|
39
|
+
function linkDestination(url) {
|
|
40
|
+
return url.replace(/&(?=(?:#(?:x[0-9a-f]+|[0-9]+)|[a-z][a-z0-9]*;))/gi, '%26')
|
|
41
|
+
.replace(/["'`]/g, character => '%' + character.charCodeAt(0).toString(16).toUpperCase());
|
|
42
|
+
}
|
|
43
|
+
/** Escape prose and disable unsupported links while retaining safe HTTP(S) destinations. */
|
|
36
44
|
function safeLinks(text) {
|
|
37
45
|
let result = '', cursor = 0;
|
|
38
46
|
while (cursor < text.length) {
|
|
39
47
|
const start = text.indexOf('](', cursor);
|
|
40
48
|
if (start < 0)
|
|
41
|
-
return result + text.slice(cursor);
|
|
49
|
+
return result + escapeProse(text.slice(cursor));
|
|
42
50
|
let end = start + 2, depth = 1;
|
|
43
51
|
for (; end < text.length; end++) {
|
|
44
52
|
if (text[end] === '\\') {
|
|
@@ -51,12 +59,13 @@ function safeLinks(text) {
|
|
|
51
59
|
break;
|
|
52
60
|
}
|
|
53
61
|
if (end >= text.length)
|
|
54
|
-
return result + text.slice(cursor);
|
|
62
|
+
return result + escapeProse(text.slice(cursor));
|
|
55
63
|
const destination = text.slice(start + 2, end).trim();
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const parts = destination.match(/^(<[^>]+>|\S+?)(\s+["'][\s\S]*["'])?$/);
|
|
65
|
+
const url = parts?.[1]?.replace(/^<|>$/g, '') ?? '';
|
|
66
|
+
result += escapeProse(text.slice(cursor, start)) + (externalUrl(url)
|
|
67
|
+
? `](${linkDestination(url)}${escapeProse(parts?.[2] ?? '')})`
|
|
68
|
+
: `](${escapeProse(destination)})`);
|
|
60
69
|
cursor = end + 1;
|
|
61
70
|
}
|
|
62
71
|
return result;
|
|
@@ -65,10 +74,9 @@ function prose(text) {
|
|
|
65
74
|
let output = text
|
|
66
75
|
.replace(/<(https?:\/\/[^<>\s]+)>/gi, (_match, url) => externalUrl(url) ? `[${url}](${url})` : url)
|
|
67
76
|
.replace(/!\[/g, '[');
|
|
68
|
-
output = safeLinks(output);
|
|
69
77
|
output = output.replace(/^( {0,3}\[[^\]\n]+\]):\s*(<?\S+>?)(.*)$/gm, (line, label, destination) => externalUrl(destination.replace(/^<|>$/g, '')) ? line : line.replace(`${label}:`, `${label}:`));
|
|
70
|
-
//
|
|
71
|
-
output = output
|
|
78
|
+
// Prose still escapes entities so encoded native tags cannot become active.
|
|
79
|
+
output = safeLinks(output);
|
|
72
80
|
return output.replace(/^[ \t]*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?[ \t]*$/gm, line => {
|
|
73
81
|
const cells = line.trim().replace(/^\||\|$/g, '').split('|');
|
|
74
82
|
return `| ${cells.map(() => ':---').join(' | ')} |`;
|
|
@@ -169,7 +177,8 @@ export function renderPanel(panel) {
|
|
|
169
177
|
const metrics = (panel.metrics ?? []).filter(metric => metric.label.trim() || metric.value.trim());
|
|
170
178
|
const fallbackText = safePlainText([panel.title, panel.subtitle, panel.status?.text,
|
|
171
179
|
...metrics.map(metric => [`${metric.label}:${metric.value}`, metric.note].filter(Boolean).join('\n')),
|
|
172
|
-
...panel.sections.map(section => [section.title, section.text].filter(Boolean).join('\n')),
|
|
180
|
+
...panel.sections.map(section => [section.title, section.text].filter(Boolean).join('\n')),
|
|
181
|
+
...(panel.select?.options.map(option => `${option.label}\n/resume ${option.value}`) ?? []), panel.note]
|
|
173
182
|
.filter(Boolean).join('\n\n'));
|
|
174
183
|
const elements = [];
|
|
175
184
|
const actions = panel.actions ?? [];
|
|
@@ -187,6 +196,23 @@ export function renderPanel(panel) {
|
|
|
187
196
|
], { background_style: 'grey', padding: '10px 12px 10px 12px' })),
|
|
188
197
|
});
|
|
189
198
|
}
|
|
199
|
+
if (panel.search)
|
|
200
|
+
elements.push({ tag: 'form', name: 'dsh_sessions_search_form', direction: 'vertical',
|
|
201
|
+
elements: [{ tag: 'column_set', flex_mode: 'none', horizontal_spacing: '8px', margin: '0px', columns: [column([
|
|
202
|
+
{ tag: 'input', name: 'query', input_type: 'text', placeholder: title('搜索话题标题'), default_value: panel.search.query,
|
|
203
|
+
max_length: 80, width: 'fill', disabled: Boolean(panel.search.disabled) },
|
|
204
|
+
]), column([
|
|
205
|
+
{ tag: 'button', name: 'dsh_sessions_search', text: title('搜索'), type: 'default', width: 'default',
|
|
206
|
+
action_type: 'form_submit', form_action_type: 'submit', form_name: 'dsh_sessions_search_form',
|
|
207
|
+
disabled: Boolean(panel.search.disabled), behaviors: [{ type: 'callback', value: { ...panel.search.value } }] },
|
|
208
|
+
], { width: 'auto' })] }] });
|
|
209
|
+
if (panel.select)
|
|
210
|
+
elements.push({ tag: 'select_static', name: 'dsh_session_picker', width: 'fill',
|
|
211
|
+
placeholder: title(panel.select.placeholder), disabled: Boolean(panel.select.disabled),
|
|
212
|
+
...(panel.select.initialOption ? { initial_option: panel.select.initialOption } : {}),
|
|
213
|
+
options: panel.select.options.map(option => ({ text: title(safePlainText(option.label)), value: option.value })),
|
|
214
|
+
behaviors: [{ type: 'callback', value: { ...panel.select.value } }],
|
|
215
|
+
});
|
|
190
216
|
for (const section of panel.sections) {
|
|
191
217
|
if (!section.text.trim() && !section.title?.trim())
|
|
192
218
|
continue;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SavedSession } from './session-store.js';
|
|
2
|
+
import { type ReplyAction, type ReplyPanel } from './replies.js';
|
|
3
|
+
export interface SessionView {
|
|
4
|
+
page: number;
|
|
5
|
+
query: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SessionChoice extends SavedSession {
|
|
8
|
+
link?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const SESSION_PAGE_SIZE = 20;
|
|
11
|
+
export declare const SESSION_QUERY_LENGTH = 80;
|
|
12
|
+
export declare function sessionQuery(value: unknown): string | undefined;
|
|
13
|
+
/** The query travels inside the signed command, never as an executable command fragment. */
|
|
14
|
+
export declare function sessionViewCommand(kind: 'page' | 'pick', view: SessionView): string;
|
|
15
|
+
export declare function sessionViewCommandParse(command: string): {
|
|
16
|
+
kind: 'page' | 'pick';
|
|
17
|
+
view: SessionView;
|
|
18
|
+
} | undefined;
|
|
19
|
+
export declare function sessionPage<T extends SessionChoice>(sessions: readonly T[], view: SessionView): {
|
|
20
|
+
items: T[];
|
|
21
|
+
total: number;
|
|
22
|
+
pages: number;
|
|
23
|
+
view: {
|
|
24
|
+
page: number;
|
|
25
|
+
query: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** A short picker stays the same height as the catalog grows. Only its selected topic gets a detail row. */
|
|
29
|
+
export declare function sessionControlPanel(sessions: readonly SessionChoice[], currentId: string | undefined, action: (label: string, command: string) => ReplyAction | undefined, options?: {
|
|
30
|
+
view?: SessionView;
|
|
31
|
+
selectedId?: string;
|
|
32
|
+
busy?: boolean;
|
|
33
|
+
status?: ReplyPanel['status'];
|
|
34
|
+
}): ReplyPanel;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { plainLabel } from './replies.js';
|
|
2
|
+
export const SESSION_PAGE_SIZE = 20;
|
|
3
|
+
export const SESSION_QUERY_LENGTH = 80;
|
|
4
|
+
export function sessionQuery(value) {
|
|
5
|
+
if (typeof value !== 'string' || Array.from(value).length > SESSION_QUERY_LENGTH || /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value))
|
|
6
|
+
return;
|
|
7
|
+
return value.normalize('NFC').trim();
|
|
8
|
+
}
|
|
9
|
+
/** The query travels inside the signed command, never as an executable command fragment. */
|
|
10
|
+
export function sessionViewCommand(kind, view) {
|
|
11
|
+
return `/sessions ${kind} ${view.page + 1}${view.query ? ` ${Buffer.from(view.query).toString('base64url')}` : ''}`;
|
|
12
|
+
}
|
|
13
|
+
export function sessionViewCommandParse(command) {
|
|
14
|
+
const match = /^\/sessions (page|pick) ([1-9][0-9]?)(?: ([A-Za-z0-9_-]{1,428}))?$/.exec(command);
|
|
15
|
+
if (!match)
|
|
16
|
+
return;
|
|
17
|
+
const query = match[3] ? Buffer.from(match[3], 'base64url').toString('utf8') : '';
|
|
18
|
+
if (sessionQuery(query) !== query || (match[3] && Buffer.from(query).toString('base64url') !== match[3]))
|
|
19
|
+
return;
|
|
20
|
+
return { kind: match[1], view: { page: Number(match[2]) - 1, query } };
|
|
21
|
+
}
|
|
22
|
+
export function sessionPage(sessions, view) {
|
|
23
|
+
const query = view.query.normalize('NFC').toLocaleLowerCase();
|
|
24
|
+
const matches = sessions.filter(session => session.title.normalize('NFC').toLocaleLowerCase().includes(query))
|
|
25
|
+
.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
26
|
+
const pages = Math.max(1, Math.ceil(matches.length / SESSION_PAGE_SIZE));
|
|
27
|
+
const page = Math.max(0, Math.min(view.page, pages - 1));
|
|
28
|
+
return { items: matches.slice(page * SESSION_PAGE_SIZE, (page + 1) * SESSION_PAGE_SIZE), total: matches.length, pages, view: { ...view, page } };
|
|
29
|
+
}
|
|
30
|
+
/** A short picker stays the same height as the catalog grows. Only its selected topic gets a detail row. */
|
|
31
|
+
export function sessionControlPanel(sessions, currentId, action, options = {}) {
|
|
32
|
+
const page = sessionPage(sessions, options.view ?? { page: 0, query: '' });
|
|
33
|
+
const selected = sessions.find(session => session.id === (options.selectedId ?? currentId));
|
|
34
|
+
const selectedVisible = page.items.some(session => session.id === selected?.id);
|
|
35
|
+
const pick = action('选择话题', sessionViewCommand('pick', page.view));
|
|
36
|
+
const search = action('搜索', '/sessions search');
|
|
37
|
+
const actions = [
|
|
38
|
+
...(page.view.page > 0 ? [action('上一页', sessionViewCommand('page', { ...page.view, page: page.view.page - 1 }))] : []),
|
|
39
|
+
...(page.view.page + 1 < page.pages ? [action('下一页', sessionViewCommand('page', { ...page.view, page: page.view.page + 1 }))] : []),
|
|
40
|
+
action('新建话题', '/new'),
|
|
41
|
+
].filter((item) => Boolean(item));
|
|
42
|
+
const duplicates = new Map();
|
|
43
|
+
const dateFormat = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
|
44
|
+
for (const session of page.items) {
|
|
45
|
+
const title = plainLabel(session.title, 30);
|
|
46
|
+
duplicates.set(title, (duplicates.get(title) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
const count = page.view.query ? `找到 ${page.total} 个话题 · 共 ${sessions.length} 个` : `共 ${sessions.length} 个话题 · 最近活跃优先`;
|
|
49
|
+
return { title: '以前的对话', summary: page.view.query ? `搜索历史话题:${page.total} 个结果,可选择话题继续` : `浏览 ${sessions.length} 个历史话题,选择后打开继续聊天`,
|
|
50
|
+
subtitle: count, ...(options.status ? { status: options.status, tone: options.status.tone } : {}),
|
|
51
|
+
...(search ? { search: { query: page.view.query, value: search.value, disabled: options.busy } } : {}),
|
|
52
|
+
...(pick && page.items.length ? { select: { placeholder: '选择一个话题继续聊天', value: pick.value, disabled: options.busy,
|
|
53
|
+
...(selectedVisible ? { initialOption: selected.id } : {}),
|
|
54
|
+
options: page.items.map(session => {
|
|
55
|
+
const title = plainLabel(session.title, 30);
|
|
56
|
+
const date = dateFormat.format(session.updatedAt);
|
|
57
|
+
return { value: session.id, label: `${title}${Array.from(session.title).length > 30 ? '…' : ''} · ${date}${duplicates.get(title) > 1 ? ` · ${session.id.slice(-4)}` : ''}` };
|
|
58
|
+
}) } } : {}),
|
|
59
|
+
sections: !page.total ? [{ text: sessions.length ? '没有找到匹配的话题。换个关键词,或清空搜索查看全部。' : '还没有历史话题。直接发消息,就会开始新的对话。' }]
|
|
60
|
+
: !pick ? page.items.map(session => ({ title: plainLabel(session.title, 60), text: `/resume ${session.id}` }))
|
|
61
|
+
: selected ? [{ ...(!selectedVisible ? { title: plainLabel(selected.title, 60) } : {}), text: selected.link ? `[打开话题继续](${selected.link})`
|
|
62
|
+
: selected.id === currentId ? '当前正在聊的话题' : '话题入口暂不可用,请在聊天中找到对应话题后继续。' }] : [],
|
|
63
|
+
actions, actionColumns: actions.length === 1 ? 1 : actions.length === 2 ? 2 : 3,
|
|
64
|
+
note: `第 ${page.view.page + 1} / ${page.pages} 页${page.view.query ? ' · 清空搜索可查看全部话题' : ''}${!pick ? '\n文字操作:/sessions detail;/resume <ID>' : ''}`, };
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=session-controls.js.map
|
package/dist/session-store.d.ts
CHANGED
package/dist/session-store.js
CHANGED
|
@@ -30,7 +30,8 @@ function validKey(value) {
|
|
|
30
30
|
&& value === value.trim() && !CONTROLS.test(value);
|
|
31
31
|
}
|
|
32
32
|
function validateContext(value) {
|
|
33
|
-
if (!value || !validKey(value.ownerKey) || !validKey(value.conversationKey)
|
|
33
|
+
if (!value || !validKey(value.ownerKey) || !validKey(value.conversationKey)
|
|
34
|
+
|| (value.sessionId !== undefined && (typeof value.sessionId !== 'string' || !SESSION_ID.test(value.sessionId))))
|
|
34
35
|
throw new SessionStoreError('INVALID_INPUT');
|
|
35
36
|
}
|
|
36
37
|
function titleText(value) {
|
|
@@ -175,7 +176,7 @@ export class SessionStore {
|
|
|
175
176
|
}
|
|
176
177
|
mutate(context, update) {
|
|
177
178
|
// Snapshot caller-owned identity before an earlier queued mutation awaits I/O.
|
|
178
|
-
const identity = {
|
|
179
|
+
const identity = { ...context };
|
|
179
180
|
const job = this.tail.then(async () => {
|
|
180
181
|
this.assertAvailable();
|
|
181
182
|
validateContext(identity);
|
|
@@ -196,7 +197,9 @@ export class SessionStore {
|
|
|
196
197
|
await this.tail;
|
|
197
198
|
this.assertAvailable();
|
|
198
199
|
const entry = this.locate(this.document, identity);
|
|
199
|
-
const current = entry?.sessions.find(session => session.id === entry.activeId);
|
|
200
|
+
const current = entry?.sessions.find(session => session.id === (identity.sessionId ?? entry.activeId));
|
|
201
|
+
if (identity.sessionId && !current)
|
|
202
|
+
throw new SessionStoreError('NOT_FOUND');
|
|
200
203
|
return current && { ...current };
|
|
201
204
|
}
|
|
202
205
|
async list(context) {
|
|
@@ -210,6 +213,8 @@ export class SessionStore {
|
|
|
210
213
|
add(context, nativeId, title = '新会话') {
|
|
211
214
|
const identity = { ...context };
|
|
212
215
|
return this.mutate(identity, draft => {
|
|
216
|
+
if (identity.sessionId !== undefined)
|
|
217
|
+
throw new SessionStoreError('INVALID_INPUT');
|
|
213
218
|
if (!NATIVE_ID.test(nativeId))
|
|
214
219
|
throw new SessionStoreError('INVALID_INPUT');
|
|
215
220
|
const safeTitle = titleText(title);
|
|
@@ -228,7 +233,7 @@ export class SessionStore {
|
|
|
228
233
|
const time = this.nextTimestamp(draft);
|
|
229
234
|
const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time };
|
|
230
235
|
if (!entry) {
|
|
231
|
-
entry = {
|
|
236
|
+
entry = { ownerKey: identity.ownerKey, conversationKey: identity.conversationKey, activeId: null, sessions: [] };
|
|
232
237
|
draft.contexts.push(entry);
|
|
233
238
|
}
|
|
234
239
|
entry.sessions.push(session);
|
|
@@ -275,8 +280,8 @@ export class SessionStore {
|
|
|
275
280
|
const identity = { ...context };
|
|
276
281
|
return this.mutate(identity, draft => {
|
|
277
282
|
const entry = this.locate(draft, identity);
|
|
278
|
-
const changed = Boolean(entry?.activeId);
|
|
279
|
-
if (entry)
|
|
283
|
+
const changed = Boolean(entry?.activeId && (!identity.sessionId || identity.sessionId === entry.activeId));
|
|
284
|
+
if (entry && changed)
|
|
280
285
|
entry.activeId = null;
|
|
281
286
|
return { value: undefined, changed };
|
|
282
287
|
});
|
|
@@ -286,7 +291,7 @@ export class SessionStore {
|
|
|
286
291
|
return this.mutate(identity, draft => {
|
|
287
292
|
const safeTitle = titleText(title);
|
|
288
293
|
const entry = this.locate(draft, identity);
|
|
289
|
-
const session = entry?.sessions.find(candidate => candidate.id === entry.activeId);
|
|
294
|
+
const session = entry?.sessions.find(candidate => candidate.id === (identity.sessionId ?? entry.activeId));
|
|
290
295
|
if (!session)
|
|
291
296
|
throw new SessionStoreError('NO_CURRENT');
|
|
292
297
|
session.title = safeTitle;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The entry itself owns the native thread; it needs no separate navigation confirmation. */
|
|
2
|
+
export function topicEntryPanel(title, chatType) {
|
|
3
|
+
return { title, summary: `${title},在话题内继续对话`,
|
|
4
|
+
sections: [{ text: '在下方点「回复话题」,继续聊天。' }],
|
|
5
|
+
...(chatType === 'group' ? { note: '群聊中每条消息仍需 @机器人。' } : {}),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=topic-cards.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface TopicScope {
|
|
2
|
+
ownerKey: string;
|
|
3
|
+
chatKey: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TopicReceipt {
|
|
6
|
+
threadId: string;
|
|
7
|
+
messageId: string;
|
|
8
|
+
link?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Keys come exclusively from verified events and confirmed bot receipts. No message text is stored. */
|
|
11
|
+
export interface TopicBinding extends TopicScope, TopicReceipt {
|
|
12
|
+
laneKey: string;
|
|
13
|
+
conversationKey: string;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
groupAccessKey?: string;
|
|
16
|
+
}
|
|
17
|
+
type ErrorCode = 'INVALID_INPUT' | 'CORRUPT' | 'CONFLICT' | 'CAPACITY' | 'WRITE_FAILED' | 'UNAVAILABLE';
|
|
18
|
+
export declare class TopicStoreError extends Error {
|
|
19
|
+
readonly code: ErrorCode;
|
|
20
|
+
constructor(code: ErrorCode);
|
|
21
|
+
}
|
|
22
|
+
/** Use only links returned by Lark's read API/CLI enrichment; never synthesize a deep link. */
|
|
23
|
+
export declare function topicLink(value: unknown): string | undefined;
|
|
24
|
+
/** The state lock owns this store. Ambiguous publication poisons it until restart. */
|
|
25
|
+
export declare class TopicStore {
|
|
26
|
+
private readonly directory;
|
|
27
|
+
private entries;
|
|
28
|
+
private tail;
|
|
29
|
+
private unavailable;
|
|
30
|
+
private constructor();
|
|
31
|
+
static open(directory: string): Promise<TopicStore>;
|
|
32
|
+
private validate;
|
|
33
|
+
private assertAvailable;
|
|
34
|
+
list(scope: TopicScope): Promise<TopicBinding[]>;
|
|
35
|
+
get(scope: TopicScope, threadId: string): Promise<TopicBinding | undefined>;
|
|
36
|
+
occupied(chatKey: string, threadId: string): Promise<boolean>;
|
|
37
|
+
record(binding: TopicBinding): Promise<TopicBinding>;
|
|
38
|
+
private persist;
|
|
39
|
+
}
|
|
40
|
+
export {};
|