@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/NOTICE +15 -0
- package/README.md +56 -2
- package/client.js +4189 -0
- package/cordis.patch.yml +4 -0
- package/docs/coexistence.md +112 -0
- package/docs/dsh-feishu-channels-plan.md +1406 -0
- package/docs/ui-revamp-plan.md +263 -0
- package/docs/verification.md +120 -0
- package/icon.svg +17 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -4
- package/src/channels/connection-state.js +12 -0
- package/src/channels/diagnostics.js +77 -0
- package/src/channels/factory.js +20 -0
- package/src/channels/feishu-app.js +267 -0
- package/src/channels/feishu.js +399 -0
- package/src/channels/media.js +9 -0
- package/src/channels/meta.js +24 -0
- package/src/channels/qr/feishu.js +84 -0
- package/src/channels/qr/hub.js +229 -0
- package/src/channels/qr/render.js +8 -0
- package/src/channels/qr/shared.js +50 -0
- package/src/engine/abort.js +35 -0
- package/src/engine/access.js +23 -0
- package/src/engine/agent-options.js +19 -0
- package/src/engine/approval.js +70 -0
- package/src/engine/assistant-settings.js +38 -0
- package/src/engine/assistant-text.js +11 -0
- package/src/engine/atomic-file.js +45 -0
- package/src/engine/chat-commands.js +752 -0
- package/src/engine/choice-delivery.js +20 -0
- package/src/engine/choices.js +103 -0
- package/src/engine/command-locale.js +70 -0
- package/src/engine/command-messages.js +434 -0
- package/src/engine/command-permissions.js +38 -0
- package/src/engine/command-replies.js +59 -0
- package/src/engine/credentials.js +54 -0
- package/src/engine/deferred-delivery.js +260 -0
- package/src/engine/delivery-history.js +39 -0
- package/src/engine/file-delivery.js +215 -0
- package/src/engine/file-input.js +52 -0
- package/src/engine/file-log.js +25 -0
- package/src/engine/gateway.js +1337 -0
- package/src/engine/image-input.js +31 -0
- package/src/engine/json-state.js +25 -0
- package/src/engine/keyed-queue.js +20 -0
- package/src/engine/merge.js +56 -0
- package/src/engine/message-progress.js +274 -0
- package/src/engine/question.js +201 -0
- package/src/engine/quiet-logger.js +25 -0
- package/src/engine/reply-stream.js +86 -0
- package/src/engine/router.js +713 -0
- package/src/engine/secure-file.js +8 -0
- package/src/engine/seen-store.js +43 -0
- package/src/engine/session-export.js +64 -0
- package/src/engine/session-history.js +30 -0
- package/src/engine/session-id.js +62 -0
- package/src/engine/session-store.js +109 -0
- package/src/engine/session-title.js +29 -0
- package/src/engine/split.js +291 -0
- package/src/engine/workspace-path.js +9 -0
- package/src/index.js +62 -0
- package/src/manager.js +1325 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { backupCorruptFileSync, writeFileAtomicSync } from './atomic-file.js';
|
|
3
|
+
/** 去重最近见过的消息 id,避免重启或重复投递打两次。 */
|
|
4
|
+
export class SeenStore {
|
|
5
|
+
file;
|
|
6
|
+
limit;
|
|
7
|
+
ids = [];
|
|
8
|
+
known = new Set();
|
|
9
|
+
constructor(file, limit = 2000) {
|
|
10
|
+
this.file = file;
|
|
11
|
+
this.limit = limit;
|
|
12
|
+
this.load();
|
|
13
|
+
}
|
|
14
|
+
has(id) {
|
|
15
|
+
return this.known.has(id);
|
|
16
|
+
}
|
|
17
|
+
add(id) {
|
|
18
|
+
if (!id || this.has(id))
|
|
19
|
+
return;
|
|
20
|
+
this.ids.push(id);
|
|
21
|
+
this.known.add(id);
|
|
22
|
+
if (this.ids.length > this.limit) {
|
|
23
|
+
this.ids = this.ids.slice(-this.limit);
|
|
24
|
+
this.known = new Set(this.ids);
|
|
25
|
+
}
|
|
26
|
+
this.flush();
|
|
27
|
+
}
|
|
28
|
+
load() {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
31
|
+
this.ids = Array.isArray(parsed.ids) ? parsed.ids.filter((item) => typeof item === 'string') : [];
|
|
32
|
+
this.known = new Set(this.ids);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
backupCorruptFileSync(this.file);
|
|
36
|
+
this.ids = [];
|
|
37
|
+
this.known.clear();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
flush() {
|
|
41
|
+
writeFileAtomicSync(this.file, `${JSON.stringify({ ids: this.ids }, null, 2)}\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { fileOperation, timeoutSignal } from './abort.js';
|
|
2
|
+
import { replyText } from './command-locale.js';
|
|
3
|
+
/** 复用宿主已注册的 Chat ZIP 路由;不连接网络,不接收任意 URL 或会话 ID 参数。 */
|
|
4
|
+
export async function exportSession(host, channel, chatId, sessionId, parent, stillCurrent) {
|
|
5
|
+
const signal = timeoutSignal(120_000, parent);
|
|
6
|
+
const fallback = () => replyText('会话未能导出或发送。请在网页 Chat 中打开这条会话,执行 /export 下载 ZIP。');
|
|
7
|
+
try {
|
|
8
|
+
signal.throwIfAborted();
|
|
9
|
+
const connection = host.get('connection');
|
|
10
|
+
if (!channel.sendFile || !connection?.createSharedFetchHandler)
|
|
11
|
+
throw new Error('export-unavailable');
|
|
12
|
+
if (!stillCurrent())
|
|
13
|
+
throw new Error('binding-changed');
|
|
14
|
+
// 这是宿主内部已授权的路由分发;准入和命令权限由 IM 网关先检查。
|
|
15
|
+
const url = new URL('http://dsh.invalid/api/session.export');
|
|
16
|
+
url.searchParams.set('sessionId', sessionId);
|
|
17
|
+
url.searchParams.set('includeDescendants', 'false');
|
|
18
|
+
// 0.2.1 的共享路由要求真实宿主 Peer;旧宿主没有此字段且忽略额外参数。
|
|
19
|
+
const pending = connection.createSharedFetchHandler('/api').fetch(new Request(url, { signal }), connection.browserPeer);
|
|
20
|
+
void pending.then(response => { if (signal.aborted)
|
|
21
|
+
void response.body?.cancel().catch(() => { }); }, () => { });
|
|
22
|
+
const response = await fileOperation(pending, signal);
|
|
23
|
+
const reader = response.body?.getReader();
|
|
24
|
+
let complete = false;
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let size = 0;
|
|
27
|
+
try {
|
|
28
|
+
if (!response.ok || !response.headers.get('content-type')?.toLowerCase().startsWith('application/zip') || !reader)
|
|
29
|
+
throw new Error('export-response');
|
|
30
|
+
for (;;) {
|
|
31
|
+
const part = await fileOperation(reader.read(), signal);
|
|
32
|
+
if (part.done) {
|
|
33
|
+
complete = true;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
size += part.value.byteLength;
|
|
37
|
+
// 渠道接口接收完整字节;流式限额避免会话附件耗尽插件内存。
|
|
38
|
+
if (size > 32 * 1024 * 1024)
|
|
39
|
+
throw new Error('export-too-large');
|
|
40
|
+
chunks.push(part.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
if (!complete)
|
|
45
|
+
void reader?.cancel().catch(() => { });
|
|
46
|
+
reader?.releaseLock();
|
|
47
|
+
}
|
|
48
|
+
if (!size)
|
|
49
|
+
throw new Error('export-empty');
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
if (!stillCurrent())
|
|
52
|
+
throw new Error('binding-changed');
|
|
53
|
+
const name = `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100)}.zip`;
|
|
54
|
+
await fileOperation(channel.sendFile(chatId, { name, data: Buffer.concat(chunks, size) }, signal), signal);
|
|
55
|
+
return replyText('会话已导出,ZIP 文件已发送。');
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (parent.aborted)
|
|
59
|
+
throw parent.reason;
|
|
60
|
+
if (error instanceof Error && error.message === 'export-too-large')
|
|
61
|
+
throw new Error(replyText('会话 ZIP 超过 32 MiB,未发送。请在网页 Chat 中执行 /export 下载。'));
|
|
62
|
+
throw new Error(fallback(), { cause: error });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 官方 Session 没有公开 events,同步活日志是 snapshotEvents。
|
|
3
|
+
* events 只服务夹具和非官方对象;空数组继续往下看,避免把“还没写上”当成没有历史。
|
|
4
|
+
*/
|
|
5
|
+
export function readLiveSessionHistory(session) {
|
|
6
|
+
if (Array.isArray(session?.events) && session.events.length > 0)
|
|
7
|
+
return session.events;
|
|
8
|
+
if (typeof session?.snapshotEvents === 'function') {
|
|
9
|
+
const snapshot = session.snapshotEvents();
|
|
10
|
+
if (Array.isArray(snapshot))
|
|
11
|
+
return snapshot;
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(session?.events))
|
|
14
|
+
return session.events;
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
/** 冷读优先 inspect;空结果或失败再回退活日志,不能把一次空存储前缀当成日志不存在。 */
|
|
18
|
+
export async function readSessionHistory(host, session, signal) {
|
|
19
|
+
const id = session?.id;
|
|
20
|
+
const controller = host?.get?.('sessionController');
|
|
21
|
+
if (id && typeof controller?.inspect === 'function') {
|
|
22
|
+
try {
|
|
23
|
+
const inspection = await controller.inspect(id, signal);
|
|
24
|
+
if (Array.isArray(inspection?.events) && inspection.events.length > 0)
|
|
25
|
+
return inspection.events;
|
|
26
|
+
}
|
|
27
|
+
catch { /* 继续回退,不能把一次冷读失败当成日志不存在。 */ }
|
|
28
|
+
}
|
|
29
|
+
return readLiveSessionHistory(session);
|
|
30
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Feishu channel session identity: separate from web tasks by id prefix. */
|
|
2
|
+
export const DFC_ORIGIN = 'dfc';
|
|
3
|
+
export const DFC_SESSION_PREFIX = 'dfc:';
|
|
4
|
+
|
|
5
|
+
export function sessionKeyOf(channel, kind, chatId) {
|
|
6
|
+
return `${channel}:${kind}:${chatId}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let lastStamp = 0;
|
|
10
|
+
|
|
11
|
+
/** New sessions use a timestamped unique id; same-millisecond creates increment. */
|
|
12
|
+
export function createFeishuSessionId(channel, kind, chatId, now = Date.now()) {
|
|
13
|
+
const stamp = now <= lastStamp ? lastStamp + 1 : now;
|
|
14
|
+
lastStamp = stamp;
|
|
15
|
+
return `${DFC_SESSION_PREFIX}${channel}:${kind}:${stamp}:${chatId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isFeishuSessionId(sessionId) {
|
|
19
|
+
return typeof sessionId === 'string' && sessionId.startsWith(DFC_SESSION_PREFIX);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CHANNEL_IDS = ['feishu'];
|
|
23
|
+
const STAMP_RE = /^\d{13,}$/;
|
|
24
|
+
|
|
25
|
+
export function parseFeishuSessionId(sessionId) {
|
|
26
|
+
if (!isFeishuSessionId(sessionId))
|
|
27
|
+
return undefined;
|
|
28
|
+
const rest = sessionId.slice(DFC_SESSION_PREFIX.length);
|
|
29
|
+
const first = rest.indexOf(':');
|
|
30
|
+
const second = rest.indexOf(':', first + 1);
|
|
31
|
+
if (first <= 0 || second <= first)
|
|
32
|
+
return undefined;
|
|
33
|
+
const channel = rest.slice(0, first);
|
|
34
|
+
const kind = rest.slice(first + 1, second);
|
|
35
|
+
let chatId = rest.slice(second + 1);
|
|
36
|
+
const third = chatId.indexOf(':');
|
|
37
|
+
if (third > 0 && STAMP_RE.test(chatId.slice(0, third))) {
|
|
38
|
+
chatId = chatId.slice(third + 1);
|
|
39
|
+
}
|
|
40
|
+
const platform = channel.split('_', 1)[0];
|
|
41
|
+
const validInstance = CHANNEL_IDS.includes(platform) && /^[a-z0-9_-]+$/.test(channel);
|
|
42
|
+
if (!validInstance || (kind !== 'dm' && kind !== 'group') || chatId === '')
|
|
43
|
+
return undefined;
|
|
44
|
+
return { channel, kind, chatId };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isFeishuOrigin(origin) {
|
|
48
|
+
return origin === DFC_ORIGIN;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Task list visibility: exclude channel sessions and subagents. */
|
|
52
|
+
export function isTaskSession(input) {
|
|
53
|
+
if (input.blank === true)
|
|
54
|
+
return false;
|
|
55
|
+
if (isFeishuOrigin(input.origin))
|
|
56
|
+
return false;
|
|
57
|
+
if (typeof input.id === 'string' && isFeishuSessionId(input.id))
|
|
58
|
+
return false;
|
|
59
|
+
if (input.origin === 'subagent')
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { constants, copyFileSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { backupCorruptFileSync, writeFileAtomicSync } from './atomic-file.js';
|
|
3
|
+
export class SessionMapStore {
|
|
4
|
+
file;
|
|
5
|
+
records = {};
|
|
6
|
+
historyBackupTaken = false;
|
|
7
|
+
constructor(file) {
|
|
8
|
+
this.file = file;
|
|
9
|
+
this.load();
|
|
10
|
+
}
|
|
11
|
+
list() {
|
|
12
|
+
return Object.values(this.records).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
13
|
+
}
|
|
14
|
+
get(key) {
|
|
15
|
+
return this.records[key];
|
|
16
|
+
}
|
|
17
|
+
/** 按会话查找不需要列表的更新时间排序。 */
|
|
18
|
+
findSession(sessionId) {
|
|
19
|
+
let detached;
|
|
20
|
+
for (const key in this.records) {
|
|
21
|
+
const record = this.records[key];
|
|
22
|
+
if (record.sessionId !== sessionId)
|
|
23
|
+
continue;
|
|
24
|
+
if (!record.detached)
|
|
25
|
+
return record;
|
|
26
|
+
detached = record;
|
|
27
|
+
}
|
|
28
|
+
return detached;
|
|
29
|
+
}
|
|
30
|
+
upsert(key, record) {
|
|
31
|
+
const before = { ...this.records };
|
|
32
|
+
const old = this.records[key];
|
|
33
|
+
if (old && old.sessionId !== record.sessionId) {
|
|
34
|
+
this.backupBeforeHistory();
|
|
35
|
+
this.records[`history:${old.sessionId}`] = old;
|
|
36
|
+
}
|
|
37
|
+
this.records[key] = record;
|
|
38
|
+
if (key !== `history:${record.sessionId}`)
|
|
39
|
+
delete this.records[`history:${record.sessionId}`];
|
|
40
|
+
try {
|
|
41
|
+
this.flush();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
this.records = before;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
retain(key) {
|
|
49
|
+
const record = this.records[key];
|
|
50
|
+
if (!record)
|
|
51
|
+
return;
|
|
52
|
+
this.backupBeforeHistory();
|
|
53
|
+
this.records[`history:${record.sessionId}`] = record;
|
|
54
|
+
delete this.records[key];
|
|
55
|
+
this.flush();
|
|
56
|
+
}
|
|
57
|
+
saveHistory(record) {
|
|
58
|
+
if (this.list().some(item => item.sessionId === record.sessionId))
|
|
59
|
+
return;
|
|
60
|
+
this.backupBeforeHistory();
|
|
61
|
+
this.upsert(`history:${record.sessionId}`, record);
|
|
62
|
+
}
|
|
63
|
+
updateSession(record) {
|
|
64
|
+
for (const key of Object.keys(this.records)) {
|
|
65
|
+
if (this.records[key]?.sessionId === record.sessionId)
|
|
66
|
+
this.records[key] = record;
|
|
67
|
+
}
|
|
68
|
+
this.flush();
|
|
69
|
+
}
|
|
70
|
+
removeSession(sessionId) {
|
|
71
|
+
for (const key of Object.keys(this.records)) {
|
|
72
|
+
if (this.records[key]?.sessionId === sessionId)
|
|
73
|
+
delete this.records[key];
|
|
74
|
+
}
|
|
75
|
+
this.flush();
|
|
76
|
+
}
|
|
77
|
+
remove(key) {
|
|
78
|
+
delete this.records[key];
|
|
79
|
+
this.flush();
|
|
80
|
+
}
|
|
81
|
+
load() {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
84
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
85
|
+
throw new TypeError('会话映射顶层必须是对象');
|
|
86
|
+
this.records = parsed;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
backupCorruptFileSync(this.file);
|
|
90
|
+
this.records = {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
flush() {
|
|
94
|
+
writeFileAtomicSync(this.file, `${JSON.stringify(this.records, null, 2)}\n`);
|
|
95
|
+
}
|
|
96
|
+
backupBeforeHistory() {
|
|
97
|
+
if (this.historyBackupTaken)
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
copyFileSync(this.file, `${this.file}.before-history.json`, constants.COPYFILE_EXCL);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const code = error.code;
|
|
104
|
+
if (code !== 'ENOENT' && code !== 'EEXIST')
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
this.historyBackupTaken = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
/** 仅接受已知宿主标题来源;未知事件不能覆盖迁移前的名称。 */
|
|
3
|
+
export function readSessionTitle(data) {
|
|
4
|
+
if (!data || typeof data !== 'object')
|
|
5
|
+
return undefined;
|
|
6
|
+
const value = data;
|
|
7
|
+
if (typeof value.title !== 'string' || !value.title.trim())
|
|
8
|
+
return undefined;
|
|
9
|
+
const kind = value.source?.kind;
|
|
10
|
+
if (kind !== 'user' && kind !== 'provider' && kind !== 'fallback')
|
|
11
|
+
return undefined;
|
|
12
|
+
return { title: value.title, source: kind === 'user' ? 'user' : 'host' };
|
|
13
|
+
}
|
|
14
|
+
/** 首条原始消息的本地兜底标题;不调用重命名接口,以免锁住宿主自动命名。 */
|
|
15
|
+
export function initialSessionTitle(text) {
|
|
16
|
+
const clean = stripVTControlCharacters(text).replace(/[\u0000-\u001f\u007f-\u009f\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu, ' ')
|
|
17
|
+
.replace(/\s+/gu, ' ').trim();
|
|
18
|
+
if (!clean)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (Buffer.byteLength(clean, 'utf8') <= 60)
|
|
21
|
+
return clean;
|
|
22
|
+
let title = '';
|
|
23
|
+
for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(clean)) {
|
|
24
|
+
if (Buffer.byteLength(title + segment, 'utf8') > 57)
|
|
25
|
+
break;
|
|
26
|
+
title += segment;
|
|
27
|
+
}
|
|
28
|
+
return `${title.trimEnd()}…`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 长文分片:按渠道上限切分,尽量保持 Markdown 结构完整。
|
|
3
|
+
*
|
|
4
|
+
* 规则(思路参考 dsh-im,按本仓库的单分片器架构实现):
|
|
5
|
+
* - 代码围栏和 GFM 管道表格作为整体参与装片,不拦腰切断;
|
|
6
|
+
* - 超长围栏拆正文,每片补齐自己的开始和结束标记;
|
|
7
|
+
* - 超长表格按行拆分,每片重复表头和分隔行;
|
|
8
|
+
* - 序号前缀只加在纯文字片开头,表格和围栏片不加,否则会破坏结构。
|
|
9
|
+
*/
|
|
10
|
+
const FENCE_MARKER = /^\s{0,3}(`{3,}|~{3,})/;
|
|
11
|
+
/** 序号前缀的最小预留空间;长文按可能的片数扩展。 */
|
|
12
|
+
const PREFIX_ALLOWANCE = 8;
|
|
13
|
+
function codePointLength(value) {
|
|
14
|
+
return [...value].length;
|
|
15
|
+
}
|
|
16
|
+
/** 表格单元格:跳过转义竖线和行内代码里的竖线,并允许省略外围竖线。 */
|
|
17
|
+
function tableCells(line) {
|
|
18
|
+
const source = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
19
|
+
const cells = [];
|
|
20
|
+
let cell = '';
|
|
21
|
+
let separators = 0;
|
|
22
|
+
let index = 0;
|
|
23
|
+
while (index < source.length) {
|
|
24
|
+
const char = source[index];
|
|
25
|
+
if (char === '\\' && index + 1 < source.length) {
|
|
26
|
+
cell += source[index + 1];
|
|
27
|
+
index += 2;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (char === '`') {
|
|
31
|
+
let run = 1;
|
|
32
|
+
while (source[index + run] === '`')
|
|
33
|
+
run += 1;
|
|
34
|
+
const marker = '`'.repeat(run);
|
|
35
|
+
const end = source.indexOf(marker, index + run);
|
|
36
|
+
if (end === -1) {
|
|
37
|
+
cell += marker;
|
|
38
|
+
index += run;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
cell += source.slice(index, end + run);
|
|
42
|
+
index = end + run;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (char === '|') {
|
|
46
|
+
cells.push(cell);
|
|
47
|
+
cell = '';
|
|
48
|
+
separators += 1;
|
|
49
|
+
index += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
cell += char;
|
|
53
|
+
index += 1;
|
|
54
|
+
}
|
|
55
|
+
if (separators === 0)
|
|
56
|
+
return null;
|
|
57
|
+
cells.push(cell);
|
|
58
|
+
if (cells[0].trim() === '')
|
|
59
|
+
cells.shift();
|
|
60
|
+
if (cells.at(-1)?.trim() === '')
|
|
61
|
+
cells.pop();
|
|
62
|
+
return cells.length > 0 ? cells : null;
|
|
63
|
+
}
|
|
64
|
+
function fenceMarkerOf(line) {
|
|
65
|
+
return FENCE_MARKER.exec(line)?.[1];
|
|
66
|
+
}
|
|
67
|
+
/** 结束标记:与开始标记同字符,且不短于开始标记,行内不能有其他内容。 */
|
|
68
|
+
function isFenceClose(line, marker) {
|
|
69
|
+
const found = fenceMarkerOf(line);
|
|
70
|
+
if (!found || found[0] !== marker[0] || found.length < marker.length)
|
|
71
|
+
return false;
|
|
72
|
+
return line.trim() === found;
|
|
73
|
+
}
|
|
74
|
+
/** 返回表格结束位置(不含);不是表格返回 -1。 */
|
|
75
|
+
function tableEnd(lines, start) {
|
|
76
|
+
const header = tableCells(lines[start] ?? '');
|
|
77
|
+
if (!header)
|
|
78
|
+
return -1;
|
|
79
|
+
const separator = tableCells(lines[start + 1] ?? '');
|
|
80
|
+
if (!separator || separator.length !== header.length)
|
|
81
|
+
return -1;
|
|
82
|
+
if (!separator.every((cell) => /^\s*:?-+:?\s*$/.test(cell)))
|
|
83
|
+
return -1;
|
|
84
|
+
let end = start + 2;
|
|
85
|
+
while (end < lines.length) {
|
|
86
|
+
const cells = tableCells(lines[end] ?? '');
|
|
87
|
+
if (!cells)
|
|
88
|
+
break;
|
|
89
|
+
end += 1;
|
|
90
|
+
}
|
|
91
|
+
return end;
|
|
92
|
+
}
|
|
93
|
+
/** 切成「纯文字 / 围栏 / 表格」三种块。未闭合的围栏会补上结束标记。 */
|
|
94
|
+
function toBlocks(text) {
|
|
95
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
96
|
+
const blocks = [];
|
|
97
|
+
let prose = [];
|
|
98
|
+
const flushProse = () => {
|
|
99
|
+
if (!prose.length)
|
|
100
|
+
return;
|
|
101
|
+
blocks.push({ kind: 'prose', text: prose.join('\n') });
|
|
102
|
+
prose = [];
|
|
103
|
+
};
|
|
104
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
105
|
+
const marker = fenceMarkerOf(lines[index] ?? '');
|
|
106
|
+
if (marker) {
|
|
107
|
+
let closing = -1;
|
|
108
|
+
for (let at = index + 1; at < lines.length; at += 1) {
|
|
109
|
+
if (isFenceClose(lines[at] ?? '', marker)) {
|
|
110
|
+
closing = at;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
flushProse();
|
|
115
|
+
const body = lines.slice(index, closing === -1 ? lines.length : closing + 1);
|
|
116
|
+
if (closing === -1)
|
|
117
|
+
body.push(marker);
|
|
118
|
+
blocks.push({ kind: 'fence', text: body.join('\n') });
|
|
119
|
+
index = (closing === -1 ? lines.length : closing + 1) - 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const end = tableEnd(lines, index);
|
|
123
|
+
if (end > index) {
|
|
124
|
+
flushProse();
|
|
125
|
+
blocks.push({ kind: 'table', text: lines.slice(index, end).join('\n') });
|
|
126
|
+
index = end - 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
prose.push(lines[index] ?? '');
|
|
130
|
+
}
|
|
131
|
+
flushProse();
|
|
132
|
+
return blocks;
|
|
133
|
+
}
|
|
134
|
+
/** 纯文字按换行、句末标点、硬切三级降级。 */
|
|
135
|
+
function splitProse(text, max) {
|
|
136
|
+
if (codePointLength(text) <= max)
|
|
137
|
+
return [text];
|
|
138
|
+
const chars = [...text];
|
|
139
|
+
const parts = [];
|
|
140
|
+
let rest = chars;
|
|
141
|
+
while (rest.length > 0) {
|
|
142
|
+
if (rest.length <= max) {
|
|
143
|
+
parts.push(rest.join(''));
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
const window = rest.slice(0, max);
|
|
147
|
+
let cut = 0;
|
|
148
|
+
for (let index = window.length - 1; index >= 0; index -= 1) {
|
|
149
|
+
if (window[index] === '\n') {
|
|
150
|
+
cut = index + 1;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (cut === 0) {
|
|
155
|
+
for (let index = window.length - 1; index >= 0; index -= 1) {
|
|
156
|
+
if ('。!?…;'.includes(window[index] ?? '')) {
|
|
157
|
+
cut = index + 1;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (cut === 0)
|
|
163
|
+
cut = max;
|
|
164
|
+
parts.push(rest.slice(0, cut).join(''));
|
|
165
|
+
rest = rest.slice(cut);
|
|
166
|
+
}
|
|
167
|
+
return parts;
|
|
168
|
+
}
|
|
169
|
+
/** 超长围栏:拆正文,每片补齐自己的开始和结束标记。 */
|
|
170
|
+
function splitFence(lines, max) {
|
|
171
|
+
if (lines.length < 2)
|
|
172
|
+
return [];
|
|
173
|
+
const opening = lines[0];
|
|
174
|
+
const closing = lines.at(-1);
|
|
175
|
+
const bodyMax = max - codePointLength(opening) - codePointLength(closing) - 2;
|
|
176
|
+
if (bodyMax < 16)
|
|
177
|
+
return [];
|
|
178
|
+
return splitProse(lines.slice(1, -1).join('\n'), bodyMax)
|
|
179
|
+
.map((piece) => `${opening}\n${piece}${piece.endsWith('\n') ? '' : '\n'}${closing}`);
|
|
180
|
+
}
|
|
181
|
+
/** 超长表格:按行拆分,每片重复表头和分隔行。 */
|
|
182
|
+
function splitTable(lines, max) {
|
|
183
|
+
const prefix = `${lines[0] ?? ''}\n${lines[1] ?? ''}`;
|
|
184
|
+
const rowMax = max - codePointLength(prefix) - 1;
|
|
185
|
+
if (rowMax < 8)
|
|
186
|
+
return [];
|
|
187
|
+
const pieces = [];
|
|
188
|
+
let current = prefix;
|
|
189
|
+
let filled = false;
|
|
190
|
+
const flush = () => {
|
|
191
|
+
if (!filled)
|
|
192
|
+
return;
|
|
193
|
+
pieces.push(current);
|
|
194
|
+
current = prefix;
|
|
195
|
+
filled = false;
|
|
196
|
+
};
|
|
197
|
+
for (const row of lines.slice(2)) {
|
|
198
|
+
for (const rowPiece of codePointLength(row) <= rowMax ? [row] : splitProse(row, rowMax)) {
|
|
199
|
+
if (filled && codePointLength(`${current}\n${rowPiece}`) > max)
|
|
200
|
+
flush();
|
|
201
|
+
current = `${current}\n${rowPiece}`;
|
|
202
|
+
filled = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
flush();
|
|
206
|
+
return pieces;
|
|
207
|
+
}
|
|
208
|
+
function splitBlock(block, max) {
|
|
209
|
+
if (codePointLength(block.text) <= max)
|
|
210
|
+
return [{ text: block.text, plain: block.kind === 'prose' }];
|
|
211
|
+
const lines = block.text.split('\n');
|
|
212
|
+
if (block.kind === 'fence') {
|
|
213
|
+
const pieces = splitFence(lines, max);
|
|
214
|
+
if (pieces.length)
|
|
215
|
+
return pieces.map((piece) => ({ text: piece, plain: false }));
|
|
216
|
+
}
|
|
217
|
+
if (block.kind === 'table') {
|
|
218
|
+
const pieces = splitTable(lines, max);
|
|
219
|
+
if (pieces.length)
|
|
220
|
+
return pieces.map((piece) => ({ text: piece, plain: false }));
|
|
221
|
+
}
|
|
222
|
+
return splitProse(block.text, max).map((piece) => ({ text: piece, plain: block.kind === 'prose' }));
|
|
223
|
+
}
|
|
224
|
+
export function markdownTableOffsets(text) {
|
|
225
|
+
const lines = (text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
226
|
+
const offsets = [];
|
|
227
|
+
let offset = 0;
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
offsets.push(offset);
|
|
230
|
+
offset += line.length + 1;
|
|
231
|
+
}
|
|
232
|
+
const starts = [];
|
|
233
|
+
let marker;
|
|
234
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
235
|
+
const line = lines[index] ?? '';
|
|
236
|
+
if (marker) {
|
|
237
|
+
if (isFenceClose(line, marker))
|
|
238
|
+
marker = undefined;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const opening = fenceMarkerOf(line);
|
|
242
|
+
if (opening) {
|
|
243
|
+
marker = opening;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const end = tableEnd(lines, index);
|
|
247
|
+
if (end > index) {
|
|
248
|
+
starts.push(offsets[index]);
|
|
249
|
+
index = end - 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return starts;
|
|
253
|
+
}
|
|
254
|
+
export function splitText(text, max) {
|
|
255
|
+
if (max <= 0)
|
|
256
|
+
return text === '' ? [] : [text];
|
|
257
|
+
if (text === '')
|
|
258
|
+
return [];
|
|
259
|
+
const allowance = Math.max(PREFIX_ALLOWANCE, 2 * String(codePointLength(text)).length + 3);
|
|
260
|
+
// 极小上限优先保留正文,避免编号本身占满整片。
|
|
261
|
+
const numbered = max > allowance + 16;
|
|
262
|
+
const budget = numbered ? max - allowance : max;
|
|
263
|
+
const segments = toBlocks(text)
|
|
264
|
+
.flatMap((block) => splitBlock(block, block.kind === 'prose' ? budget : max))
|
|
265
|
+
.filter((segment) => segment.text !== '');
|
|
266
|
+
const chunks = [];
|
|
267
|
+
for (const segment of segments) {
|
|
268
|
+
const last = chunks.at(-1);
|
|
269
|
+
if (last) {
|
|
270
|
+
const merged = [...last.map((item) => item.text), segment.text].join('\n');
|
|
271
|
+
if (codePointLength(merged) <= (last[0].plain ? budget : max)) {
|
|
272
|
+
last.push(segment);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
chunks.push([segment]);
|
|
277
|
+
}
|
|
278
|
+
const parts = chunks
|
|
279
|
+
.map((chunk) => {
|
|
280
|
+
const text = chunk.map((item) => item.text).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
281
|
+
const firstLine = text.split('\n', 1)[0] ?? '';
|
|
282
|
+
// 前缀只能加在纯文字开头:围栏标记或表格首行前面加序号会破坏结构。
|
|
283
|
+
const structural = firstLine === '' || FENCE_MARKER.test(firstLine) || tableEnd(text.split('\n'), 0) > 0;
|
|
284
|
+
return { text, plain: chunk[0].plain && !structural };
|
|
285
|
+
})
|
|
286
|
+
.filter((part) => part.text !== '');
|
|
287
|
+
if (parts.length <= 1)
|
|
288
|
+
return parts.map((part) => part.text);
|
|
289
|
+
const total = parts.length;
|
|
290
|
+
return parts.map((part, index) => (numbered && part.plain ? `(${index + 1}/${total})${part.text}` : part.text));
|
|
291
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** 按运行平台比较工作区路径,避免在大小写敏感文件系统上错误复用会话。 */
|
|
2
|
+
export function sameWorkspacePath(left, right, platform = process.platform) {
|
|
3
|
+
const normalize = (value) => {
|
|
4
|
+
const separators = platform === 'win32' ? /[\\/]+/g : /\/+/g;
|
|
5
|
+
const normalized = value.replace(separators, '/').replace(/\/+$/, '');
|
|
6
|
+
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
7
|
+
};
|
|
8
|
+
return normalize(left) === normalize(right);
|
|
9
|
+
}
|