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.
@@ -1,4 +1,5 @@
1
- import type { ReplyPayload } from './replies.js';
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 {
@@ -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, DshRuntime } from '../dsh-runtime.js';
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: async (config, signal) => {
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
- /** Turn unsupported links into visible inert text; preserve ordinary web links. */
35
+ function escapeProse(text) {
36
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 url = destination.match(/^(<[^>]+>|\S+?)(?:\s+["'][\s\S]*["'])?$/)?.[1]?.replace(/^<|>$/g, '') ?? '';
57
- result += text.slice(cursor, start) + (externalUrl(url)
58
- ? text.slice(start, end + 1)
59
- : `](${destination})`);
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
- // Escape entities too, so an encoded <at> cannot become an active tag after decoding.
71
- output = output.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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')), panel.note]
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
@@ -1,6 +1,8 @@
1
1
  export interface SessionContext {
2
2
  ownerKey: string;
3
3
  conversationKey: string;
4
+ /** A topic may point at one historical session without changing its siblings' active selection. */
5
+ sessionId?: string;
4
6
  }
5
7
  export interface SavedSession {
6
8
  id: string;
@@ -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 = { ownerKey: context?.ownerKey, conversationKey: context?.conversationKey };
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 = { ...identity, activeId: null, sessions: [] };
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,3 @@
1
+ import type { ReplyPanel } from './replies.js';
2
+ /** The entry itself owns the native thread; it needs no separate navigation confirmation. */
3
+ export declare function topicEntryPanel(title: string, chatType: 'p2p' | 'group'): ReplyPanel;
@@ -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 {};