@jesonliu/lark-claudecode-bridge 1.4.3 → 2.0.1
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/assets/web/css/main.css +774 -574
- package/assets/web/index.html +37 -17
- package/assets/web/js/auth.js +218 -0
- package/assets/web/js/bootstrap.js +36 -34
- package/assets/web/js/core.js +4 -3
- package/assets/web/js/main.js +79 -20
- package/assets/web/js/markdown.js +64 -0
- package/assets/web/js/pages/apps.js +338 -251
- package/assets/web/js/pages/chats.js +481 -0
- package/assets/web/js/pages/claude.js +243 -180
- package/assets/web/js/pages/mcp.js +59 -43
- package/assets/web/js/pages/memory.js +43 -46
- package/assets/web/js/pages/overview.js +426 -88
- package/assets/web/js/pages/permissions.js +121 -34
- package/assets/web/js/pages/plugins.js +51 -37
- package/assets/web/js/pages/skills.js +68 -92
- package/assets/web/js/pages/slash.js +31 -23
- package/assets/web/js/pages/workspaces.js +114 -73
- package/assets/web/js/theme.js +21 -18
- package/assets/web/js/ui.js +75 -32
- package/dist/access/access-control.d.ts +18 -0
- package/dist/access/access-control.js +29 -0
- package/dist/access/access-control.js.map +1 -1
- package/dist/bin/lcb.js +15 -0
- package/dist/bin/lcb.js.map +1 -1
- package/dist/feishu/name-cache.d.ts +15 -0
- package/dist/feishu/name-cache.js +82 -0
- package/dist/feishu/name-cache.js.map +1 -0
- package/dist/feishu/sibling-names.d.ts +1 -0
- package/dist/feishu/sibling-names.js +63 -0
- package/dist/feishu/sibling-names.js.map +1 -0
- package/dist/gateway/feishu-gateway.d.ts +24 -0
- package/dist/gateway/feishu-gateway.js +132 -0
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/gateway-registry.d.ts +18 -0
- package/dist/gateway/gateway-registry.js +14 -0
- package/dist/gateway/gateway-registry.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +52 -9
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/web/chats-api.d.ts +93 -0
- package/dist/web/chats-api.js +603 -0
- package/dist/web/chats-api.js.map +1 -0
- package/dist/web/lifecycle.d.ts +21 -0
- package/dist/web/lifecycle.js +80 -3
- package/dist/web/lifecycle.js.map +1 -1
- package/dist/web/logs-api.d.ts +37 -0
- package/dist/web/logs-api.js +141 -0
- package/dist/web/logs-api.js.map +1 -0
- package/dist/web/server.js +196 -13
- package/dist/web/server.js.map +1 -1
- package/dist/web/webauth.d.ts +44 -0
- package/dist/web/webauth.js +221 -0
- package/dist/web/webauth.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// ============ 会话 ============
|
|
2
|
+
// LCB 本地聊天时间线只读查看器(「用户+/new」会话维度):一次 /new = 一个会话条目(sessionId 粒度),
|
|
3
|
+
// 左列表平铺全部会话、按最后活跃时间降序;右内容区的话题/时间/成员由选中会话唯一决定,
|
|
4
|
+
// 只保留关键词定位。后端 /api/chats/*(chats-api.ts):树(含每通道会话条目)+ 任务时间线,
|
|
5
|
+
// 全部只读、缺数据静默容忍。任务 = 一条飞书消息触发的一次 CC 执行。
|
|
6
|
+
// 搜索过滤只切换列表项显隐、不重建 DOM(兼容中文输入法组合输入)。
|
|
7
|
+
import { $, esc, api, toast } from '../core.js';
|
|
8
|
+
import { renderMarkdown } from '../markdown.js';
|
|
9
|
+
|
|
10
|
+
/** 页面态(切页重进即重置;不做跨页缓存——树接口本身就是目录扫描,量级很轻) */
|
|
11
|
+
const ST = {
|
|
12
|
+
bots: [], // /api/chats/tree
|
|
13
|
+
app: '', chat: '', session: '', // 当前选中:机器人 + 通道 + 会话(sessionId)
|
|
14
|
+
sessions: [], // 当前机器人的会话条目(拍平 + updatedAt 降序)
|
|
15
|
+
nameByUser: {}, // userId → 显示名(access 人名 + 飞书解析名,兜底尾号)
|
|
16
|
+
kw: '', // 关键词(定位对话用;''=不检索)
|
|
17
|
+
kwHits: [], kwAt: 0, // 关键词命中的任务索引与当前指向
|
|
18
|
+
tasks: [], truncated: false, partial: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const svg = (inner, s = 14) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
|
|
22
|
+
const IC = {
|
|
23
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-3.5-3.5"/>',
|
|
24
|
+
copy: '<rect width="13" height="13" x="9" y="9" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const RESULT_BADGE = {
|
|
28
|
+
success: '<span class="tag ok">已完成</span>',
|
|
29
|
+
stopped: '<span class="tag off">已停止</span>',
|
|
30
|
+
error: '<span class="tag err">出错</span>',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 整页加载层(仅初始进页存在):盖住列表+详情区,初始链路(树→时间线)走完/出错即移除。
|
|
34
|
+
* 层移除后以下调用自动变 no-op——页内后续「查询/切会话」仍走时间线区行内「加载中…」,不重复盖罩 */
|
|
35
|
+
const bootStage = (t) => { const n = $('#chBootTxt'); if (n) n.textContent = t; };
|
|
36
|
+
const hideBoot = () => $('#chBoot')?.remove();
|
|
37
|
+
|
|
38
|
+
export const page = {
|
|
39
|
+
id: 'chats',
|
|
40
|
+
title: '会话',
|
|
41
|
+
desc: '按机器人检索会话,查看完整消息与任务执行详情',
|
|
42
|
+
legend: false,
|
|
43
|
+
icon: '<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/>',
|
|
44
|
+
async render(el) {
|
|
45
|
+
el.innerHTML = `
|
|
46
|
+
<div class="panel">
|
|
47
|
+
<header class="panel-head"><h3>查询条件</h3>
|
|
48
|
+
<div class="panel-actions"><span class="dim" style="font-size:11.5px">选择机器人后,左侧列出全部「用户 + /new」会话</span></div></header>
|
|
49
|
+
<div class="panel-body">
|
|
50
|
+
<div style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap">
|
|
51
|
+
<div><label class="fld">飞书机器人 *</label>
|
|
52
|
+
<select id="chBot" style="width:220px"></select></div>
|
|
53
|
+
<button class="btn primary" id="chGo">${svg(IC.search, 13)} 查询会话</button>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="mini-note" style="margin-top:8px">记录落盘于 <code>~/.lark-claudecode-bridge/transcripts/</code>;列表按会话最后活跃时间降序,点进即看该会话跨天全部消息。</div>
|
|
56
|
+
<div class="mini-note" id="chPermHint" hidden style="margin-top:4px;color:var(--warn-tx)">ⓘ 当前显示为飞书 ID(ou_…):请在飞书开放平台为对应机器人应用开通「获取用户基本信息」(contact:user.base:readonly)权限并发布版本;开通后约 10 分钟内自动恢复真名,无须重启。</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="chats-layout">
|
|
60
|
+
<div class="panel" style="margin:0">
|
|
61
|
+
<header class="panel-head"><h3>会话列表</h3>
|
|
62
|
+
<div class="panel-actions"><span class="tag off" id="chCnt"></span></div></header>
|
|
63
|
+
<div style="padding:10px 12px 8px"><input type="search" id="chQ" placeholder="搜索会话:标题 / 用户 / 群名"></div>
|
|
64
|
+
<div class="chat-list-panel" id="chList" style="border-top:1px solid var(--border)"></div>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="panel" style="margin:0;min-height:440px" id="chDetail"></div>
|
|
67
|
+
<div class="chats-boot" id="chBoot" role="status" aria-live="polite">
|
|
68
|
+
<div class="chats-boot-box"><span class="br-spin-sm" aria-hidden="true"></span><span id="chBootTxt">正在加载会话目录…</span></div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>`;
|
|
71
|
+
// 列表搜索实时过滤(旧版缺输入监听、输入时不过滤——本次顺手补上);IME 组合期间暂停,选词结束再过滤
|
|
72
|
+
let composing = false;
|
|
73
|
+
$('#chQ').addEventListener('compositionstart', () => { composing = true; });
|
|
74
|
+
$('#chQ').addEventListener('compositionend', () => { composing = false; applyFilter(); });
|
|
75
|
+
$('#chQ').oninput = () => { if (!composing) applyFilter(); };
|
|
76
|
+
try {
|
|
77
|
+
const r = await api('GET', '/api/chats/tree');
|
|
78
|
+
ST.bots = r.bots ?? [];
|
|
79
|
+
} catch (e) {
|
|
80
|
+
hideBoot();
|
|
81
|
+
$('#chDetail').innerHTML = `<div class="empty" style="margin-top:60px">会话树加载失败:${esc(e.message)}</div>`;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// 真名依赖 contact:user.base:readonly 权限:只要还有「未解析的 ou_ 用户」就给出指路提示
|
|
85
|
+
//(配对过的人名不受影响照常显示;全部解析到真名才隐藏提示)
|
|
86
|
+
const hasAnonOu = ST.bots.some((b) => b.channels.some((c) => c.users.some((u) => !u.name && /^ou_/i.test(u.userId))));
|
|
87
|
+
const permHint = $('#chPermHint');
|
|
88
|
+
if (permHint) permHint.hidden = !hasAnonOu;
|
|
89
|
+
const botSel = $('#chBot');
|
|
90
|
+
botSel.innerHTML = ST.bots.map((b) => `<option value="${esc(b.appId)}">${esc(b.name)}(${esc(b.appId)})</option>`).join('');
|
|
91
|
+
if (!ST.bots.length) {
|
|
92
|
+
hideBoot();
|
|
93
|
+
$('#chList').innerHTML = '<div class="empty">暂无会话记录<br><span class="mini-note">还没有任何对话落盘。在飞书里和机器人聊几句后回来看。</span></div>';
|
|
94
|
+
$('#chCnt').textContent = '0 个会话';
|
|
95
|
+
$('#chGo').disabled = true;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
$('#chGo').onclick = () => void openBot(botSel.value);
|
|
99
|
+
botSel.onchange = () => { $('#chGo').click(); };
|
|
100
|
+
// 默认选中最近活跃的第一个有会话的机器人,进来即有内容可看
|
|
101
|
+
const firstBot = ST.bots.find((b) => b.channels.some((c) => c.sessions?.length));
|
|
102
|
+
if (firstBot) {
|
|
103
|
+
botSel.value = firstBot.appId;
|
|
104
|
+
bootStage('正在加载会话详情…');
|
|
105
|
+
try {
|
|
106
|
+
await openBot(firstBot.appId);
|
|
107
|
+
} finally {
|
|
108
|
+
hideBoot(); // 成功/空态/报错任何出口都撤层,加载层绝不残留
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
hideBoot();
|
|
112
|
+
openBot(botSel.value); // 有通道但全部无会话索引(如旧数据):列表空态兜底
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** 拍平当前机器人的会话条目(跨通道)并按 updatedAt 降序;hay 供列表搜索(标题/用户/群名) */
|
|
118
|
+
function flattenSessions(app) {
|
|
119
|
+
const bot = ST.bots.find((b) => b.appId === app);
|
|
120
|
+
if (!bot) return [];
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const c of bot.channels) {
|
|
123
|
+
for (const s of c.sessions ?? []) {
|
|
124
|
+
out.push({
|
|
125
|
+
app, chatId: c.chatId, chatName: c.displayName, chatType: c.chatType,
|
|
126
|
+
sessionId: s.sessionId, title: s.title, updatedAt: s.updatedAt, users: s.users ?? [],
|
|
127
|
+
hay: `${s.title} ${(s.users ?? []).map((u) => u.name || u.userId).join(' ')} ${c.displayName}`.toLowerCase(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 切机器人:重拍平会话列表并默认选中最近活跃第一条 */
|
|
135
|
+
async function openBot(app) {
|
|
136
|
+
ST.app = app;
|
|
137
|
+
ST.sessions = flattenSessions(app);
|
|
138
|
+
renderList();
|
|
139
|
+
const first = ST.sessions[0];
|
|
140
|
+
if (first) await selectSession(first.chatId, first.sessionId);
|
|
141
|
+
else renderDetailEmpty();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function selectSession(chatId, sessionId) {
|
|
145
|
+
ST.chat = chatId;
|
|
146
|
+
ST.session = sessionId;
|
|
147
|
+
ST.kw = '';
|
|
148
|
+
ST.kwHits = [];
|
|
149
|
+
ST.kwAt = 0;
|
|
150
|
+
ST.nameByUser = {};
|
|
151
|
+
const s = curSession();
|
|
152
|
+
// 真名优先,无真名退 ou 尾号(自指记录按无真名处理,后端同理过滤)
|
|
153
|
+
for (const u of s?.users ?? []) ST.nameByUser[u.userId] = u.name && u.name !== u.userId ? u.name : `${u.userId.slice(0, 10)}…`;
|
|
154
|
+
renderList();
|
|
155
|
+
renderDetailSkeleton();
|
|
156
|
+
await loadTimeline();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const curSession = () => ST.sessions.find((s) => s.chatId === ST.chat && s.sessionId === ST.session) ?? null;
|
|
160
|
+
|
|
161
|
+
/** 会话类型徽标三态:group=群聊 / p2p|private=私聊(副行已注明「私聊」,不再重复挂标)/ 空=未知 */
|
|
162
|
+
const typeTag = (t) => t === 'group' ? '<span class="tag info">群聊</span>'
|
|
163
|
+
: (t === 'p2p' || t === 'private') ? ''
|
|
164
|
+
: '<span class="tag off" title="暂无法识别会话类型(飞书群信息拉取失败)">未知</span>';
|
|
165
|
+
/** 成员名串(= 该会话的参与用户):真名优先,无真名退 ou 尾号 */
|
|
166
|
+
const membersOf = (s) => (s?.users || [])
|
|
167
|
+
.map((u) => (u.name && u.name !== u.userId ? u.name : short(u.userId, 8)))
|
|
168
|
+
.join('、');
|
|
169
|
+
|
|
170
|
+
function renderList() {
|
|
171
|
+
const el = $('#chList');
|
|
172
|
+
if (!el) return;
|
|
173
|
+
el.innerHTML = ST.sessions.map((s, i) => `
|
|
174
|
+
<div class="chat-obj ${s.chatId === ST.chat && s.sessionId === ST.session ? 'active' : ''}" data-i="${i}" role="button" tabindex="0" title="${esc(s.title)}">
|
|
175
|
+
<span class="c-ava ${s.chatType === 'group' ? 'group' : ''}">${esc(((s.users[0]?.name || s.users[0]?.userId) || '?')[0])}</span>
|
|
176
|
+
<div class="c-main">
|
|
177
|
+
<div class="c-top"><span class="c-name">${esc(s.title)}</span><span class="c-time">${esc(fmtRel(s.updatedAt))}</span></div>
|
|
178
|
+
<div class="c-prev">${esc(membersOf(s) || '未知用户')} · ${s.chatType === 'group' ? esc(s.chatName) : '私聊'}</div>
|
|
179
|
+
<div class="c-badges">${typeTag(s.chatType)}</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>`).join('')
|
|
182
|
+
|| '<div class="empty">暂无会话记录<br><span class="mini-note">该机器人还没有可展示的会话索引(早期落盘数据不含会话边界)。</span></div>';
|
|
183
|
+
$('#chCnt').textContent = `${ST.sessions.length} 个会话`;
|
|
184
|
+
el.querySelectorAll('.chat-obj').forEach((n) => {
|
|
185
|
+
const go = () => void selectSession(ST.sessions[Number(n.dataset.i)].chatId, ST.sessions[Number(n.dataset.i)].sessionId);
|
|
186
|
+
n.onclick = go;
|
|
187
|
+
n.onkeydown = (e) => { if (e.key === 'Enter') go(); };
|
|
188
|
+
});
|
|
189
|
+
applyFilter();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 搜索过滤:只切换显隐不重建 DOM(兼容中文输入法组合输入);hay 已含标题/用户/群名 */
|
|
193
|
+
function applyFilter() {
|
|
194
|
+
const inp = $('#chQ');
|
|
195
|
+
if (!inp) return;
|
|
196
|
+
const kw = (inp.value || '').trim().toLowerCase();
|
|
197
|
+
$('#chList').querySelectorAll('.chat-obj').forEach((n) => {
|
|
198
|
+
const s = ST.sessions[Number(n.dataset.i)];
|
|
199
|
+
n.hidden = !!kw && !(s?.hay ?? n.textContent.toLowerCase()).includes(kw);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const fmtSize = (n) => {
|
|
204
|
+
if (!n) return '0 B';
|
|
205
|
+
if (n < 1024) return `${n} B`;
|
|
206
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
|
207
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** 相对时间(列表条目右上角):今天 HH:MM / 昨天 / M-D / 跨年 YYYY-M-D */
|
|
211
|
+
const fmtRel = (iso) => {
|
|
212
|
+
const ms = Date.parse(String(iso ?? ''));
|
|
213
|
+
if (!Number.isFinite(ms)) return '';
|
|
214
|
+
const d = new Date(ms);
|
|
215
|
+
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
216
|
+
const day0 = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
217
|
+
const diff = Math.round((day0(new Date()) - day0(d)) / 86400000);
|
|
218
|
+
if (diff === 0) return `今天 ${hm}`;
|
|
219
|
+
if (diff === 1) return '昨天';
|
|
220
|
+
if (d.getFullYear() === new Date().getFullYear()) return `${d.getMonth() + 1}-${d.getDate()}`;
|
|
221
|
+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
222
|
+
};
|
|
223
|
+
/** 日期段(信息行时间范围用):今天/昨天/M-D(ts 为北京时间 ISO,直接取日期段避免跨时区换算) */
|
|
224
|
+
const fmtDayRel = (ts) => {
|
|
225
|
+
const s = String(ts ?? '');
|
|
226
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
227
|
+
if (!m) return s.slice(0, 10);
|
|
228
|
+
const ms = Date.parse(s);
|
|
229
|
+
if (!Number.isFinite(ms)) return `${+m[2]}-${+m[3]}`;
|
|
230
|
+
const day0 = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
231
|
+
const d = new Date(ms);
|
|
232
|
+
const diff = Math.round((day0(new Date()) - day0(d)) / 86400000);
|
|
233
|
+
if (diff === 0) return '今天';
|
|
234
|
+
if (diff === 1) return '昨天';
|
|
235
|
+
return `${+m[2]}-${+m[3]}`;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
function renderDetailEmpty() {
|
|
239
|
+
$('#chDetail').innerHTML = '<div class="empty" style="margin-top:80px">选择左侧会话查看会话详情</div>';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderDetailSkeleton() {
|
|
243
|
+
const s = curSession();
|
|
244
|
+
const bot = ST.bots.find((b) => b.appId === ST.app);
|
|
245
|
+
$('#chDetail').innerHTML = `
|
|
246
|
+
<header class="panel-head" style="padding-bottom:12px;border-bottom:1px solid var(--border)">
|
|
247
|
+
<h3 id="chDName"></h3>${typeTag(s?.chatType)}
|
|
248
|
+
<div class="panel-actions">
|
|
249
|
+
<button class="btn sm" id="chCopyId">${svg(IC.copy, 13)} 复制会话 ID</button>
|
|
250
|
+
<button class="btn sm" id="chatRefresh" title="重拉会话树与本会话记录">↻</button>
|
|
251
|
+
</div></header>
|
|
252
|
+
<div class="panel-body" style="padding-top:6px">
|
|
253
|
+
<div class="mini-note" id="chDMeta" style="margin:0 0 8px"></div>
|
|
254
|
+
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:6px">
|
|
255
|
+
<input type="search" id="chatKw" placeholder="关键词:定位对话…" style="width:200px;padding:5px 10px;font-size:12.5px">
|
|
256
|
+
<button class="btn sm primary" id="chatGo">查询</button>
|
|
257
|
+
<span id="kwNav" hidden style="display:inline-flex;align-items:center;gap:6px">
|
|
258
|
+
<span class="mini-note" id="kwPos"></span>
|
|
259
|
+
<button class="btn sm" id="kwPrev" title="上一处">↑</button>
|
|
260
|
+
<button class="btn sm" id="kwNext" title="下一处">↓</button>
|
|
261
|
+
<button class="btn sm ghost" id="kwClear" title="清除关键词">✕</button>
|
|
262
|
+
</span>
|
|
263
|
+
<span class="mini-note" id="chatScan"></span>
|
|
264
|
+
</div>
|
|
265
|
+
<div id="chatTl" class="chats-scroll"></div>
|
|
266
|
+
</div>`;
|
|
267
|
+
$('#chDName').textContent = s?.title || ST.session.slice(0, 8);
|
|
268
|
+
// 只读信息行:聊天对象(群聊场景,私聊对象=成员本人不再重复)· 成员 · 时间范围(时间线渲染后回填)
|
|
269
|
+
$('#chDMeta').innerHTML = `${s?.chatType === 'group' ? `${esc(s?.chatName || ST.chat)} · ` : ''}成员:${esc(membersOf(s) || '未知用户')} · 时间范围:<b id="chDRange">渲染后回填</b>`;
|
|
270
|
+
$('#chCopyId').onclick = async () => {
|
|
271
|
+
try { await navigator.clipboard.writeText(ST.session); toast(`会话 ID ${ST.session} 已复制`); }
|
|
272
|
+
catch { toast('复制失败(浏览器剪贴板权限)', true); }
|
|
273
|
+
};
|
|
274
|
+
// 查询制:关键词改动不立即生效,回车或点「查询」统一应用(主人反馈:不要选一下就搜)
|
|
275
|
+
$('#chatGo').onclick = () => void loadTimeline();
|
|
276
|
+
$('#chatKw').onkeydown = (e) => { if (e.key === 'Enter') $('#chatGo').click(); };
|
|
277
|
+
$('#kwPrev').onclick = () => { ST.kwAt--; gotoKwHit(); };
|
|
278
|
+
$('#kwNext').onclick = () => { ST.kwAt++; gotoKwHit(); };
|
|
279
|
+
$('#kwClear').onclick = () => { ST.kw = ''; $('#chatKw').value = ''; renderTimeline(); };
|
|
280
|
+
$('#chatRefresh').onclick = () => void fullRefresh();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 刷新 = 重拉树 + 重拍平 + 尽量留在当前会话(不在了就落到最新一条) */
|
|
284
|
+
async function fullRefresh() {
|
|
285
|
+
try {
|
|
286
|
+
const r = await api('GET', '/api/chats/tree');
|
|
287
|
+
ST.bots = r.bots ?? [];
|
|
288
|
+
} catch (e) { toast(`刷新失败:${e.message}`, true); }
|
|
289
|
+
ST.sessions = flattenSessions(ST.app);
|
|
290
|
+
const keep = ST.sessions.find((s) => s.chatId === ST.chat && s.sessionId === ST.session);
|
|
291
|
+
renderList();
|
|
292
|
+
if (keep) await selectSession(ST.chat, ST.session);
|
|
293
|
+
else if (ST.sessions[0]) await selectSession(ST.sessions[0].chatId, ST.sessions[0].sessionId);
|
|
294
|
+
else renderDetailEmpty();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function loadTimeline() {
|
|
298
|
+
const tl = $('#chatTl');
|
|
299
|
+
if (!tl || !ST.session) return;
|
|
300
|
+
// 筛选值以 DOM 现值为准(查询按钮已把关键词写入 DOM;selectSession 流程骨架刚重建)
|
|
301
|
+
ST.kw = ($('#chatKw')?.value || '').trim();
|
|
302
|
+
ST.kwAt = 0;
|
|
303
|
+
tl.innerHTML = '<div class="hint" style="padding:6px 2px">加载中…</div>';
|
|
304
|
+
const p = new URLSearchParams({ app: ST.app, chat: ST.chat, session: ST.session });
|
|
305
|
+
try {
|
|
306
|
+
const r = await api('GET', `/api/chats/timeline?${p.toString()}`);
|
|
307
|
+
ST.tasks = r.tasks ?? [];
|
|
308
|
+
ST.truncated = !!r.truncated;
|
|
309
|
+
ST.partial = !!r.partial;
|
|
310
|
+
} catch (e) {
|
|
311
|
+
tl.innerHTML = `<div class="empty">时间线加载失败:${esc(e.message)}</div>`;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
bootStage('正在渲染消息…'); // 仅初始进页有加载层时生效;页内查询时层已移除,自动 no-op
|
|
315
|
+
renderTimeline();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 信息行时间范围回填:该会话首末任务时间(同天只写一个日期) */
|
|
319
|
+
function fillRange() {
|
|
320
|
+
const el = $('#chDRange');
|
|
321
|
+
if (!el) return;
|
|
322
|
+
if (!ST.tasks.length) { el.textContent = '—'; return; }
|
|
323
|
+
const a = ST.tasks[0];
|
|
324
|
+
const b = ST.tasks[ST.tasks.length - 1];
|
|
325
|
+
const da = String(a.ts).slice(0, 10);
|
|
326
|
+
const db = String(b.ts).slice(0, 10);
|
|
327
|
+
el.textContent = da === db
|
|
328
|
+
? `${fmtDayRel(da)} ${fmtTime(a.ts)} ~ ${fmtTime(b.ts)}`
|
|
329
|
+
: `${fmtDayRel(da)} ${fmtTime(a.ts)} ~ ${fmtDayRel(db)} ${fmtTime(b.ts)}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function renderTimeline() {
|
|
333
|
+
const tl = $('#chatTl');
|
|
334
|
+
if (!tl) return;
|
|
335
|
+
const notes = [];
|
|
336
|
+
if (ST.truncated) notes.push('已达单次返回上限,仅显示最近一部分');
|
|
337
|
+
if (ST.partial) notes.push('历史文件超量,仅扫描了部分(防呆上限 300 文件 / 150MB)');
|
|
338
|
+
$('#chatScan').textContent = notes.join(' · ');
|
|
339
|
+
if (!ST.tasks.length) {
|
|
340
|
+
tl.innerHTML = '<div class="empty">该会话暂无可读消息<br><span class="mini-note">可能早于落盘功能启用,或落盘文件已被保留期清理。</span></div>';
|
|
341
|
+
fillRange();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const botName = ST.bots.find((b) => b.appId === ST.app)?.name || '机器人';
|
|
345
|
+
let html = '';
|
|
346
|
+
let lastDate = '';
|
|
347
|
+
let idx = 0;
|
|
348
|
+
for (const t of ST.tasks) {
|
|
349
|
+
if (t.date !== lastDate) {
|
|
350
|
+
lastDate = t.date;
|
|
351
|
+
html += `<div class="cmsg-day">${esc(fmtDayRel(t.date))}</div>`;
|
|
352
|
+
}
|
|
353
|
+
html += taskHtml(t, botName, idx);
|
|
354
|
+
idx++;
|
|
355
|
+
}
|
|
356
|
+
tl.innerHTML = html;
|
|
357
|
+
fillRange();
|
|
358
|
+
// 「查看完整内容」折叠切换(用户消息)
|
|
359
|
+
tl.querySelectorAll('[data-more]').forEach((b) => b.onclick = () => {
|
|
360
|
+
const f = b.nextElementSibling;
|
|
361
|
+
f.hidden = !f.hidden;
|
|
362
|
+
b.textContent = f.hidden ? '查看完整内容' : '收起完整内容';
|
|
363
|
+
});
|
|
364
|
+
// AI 回复气泡:Markdown 视图 ↔ 纯文本原文切换
|
|
365
|
+
tl.querySelectorAll('[data-md-toggle]').forEach((b) => {
|
|
366
|
+
b.onclick = () => {
|
|
367
|
+
const task = ST.tasks[Number(b.dataset.task)];
|
|
368
|
+
const bubbleEl = b.closest('.m-bubble');
|
|
369
|
+
if (!task || !bubbleEl) return;
|
|
370
|
+
const toRaw = b.dataset.mode === 'md';
|
|
371
|
+
bubbleEl.innerHTML = toRaw ? `<div class="m-raw">${esc(task.reply)}</div>` : renderMarkdown(task.reply);
|
|
372
|
+
bubbleEl.appendChild(b);
|
|
373
|
+
b.dataset.mode = toRaw ? 'raw' : 'md';
|
|
374
|
+
b.textContent = toRaw ? 'Markdown 视图' : '查看原文';
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
bindKwNav(tl);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============ 关键词定位:任务级命中 → n/m 计数 + 上下切换 + 滚动定位 ============
|
|
381
|
+
|
|
382
|
+
/** 渲染后收集命中并跳到第一处(ST.kw 由「查询」写入) */
|
|
383
|
+
function bindKwNav(tl) {
|
|
384
|
+
const nav = $('#kwNav');
|
|
385
|
+
if (!nav) return;
|
|
386
|
+
ST.kwHits = [];
|
|
387
|
+
const kw = (ST.kw || '').toLowerCase();
|
|
388
|
+
if (kw) {
|
|
389
|
+
ST.tasks.forEach((t, i) => {
|
|
390
|
+
const hay = `${t.prompt || ''}\n${t.reply || ''}\n${(t.tools || []).map((x) => x.tool || '').join('\n')}`.toLowerCase();
|
|
391
|
+
if (hay.includes(kw)) ST.kwHits.push(i);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (!ST.kwHits.length) { nav.hidden = true; return; }
|
|
395
|
+
ST.kwAt = Math.min(ST.kwAt, ST.kwHits.length - 1);
|
|
396
|
+
nav.hidden = false;
|
|
397
|
+
gotoKwHit();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 跳到当前命中:高亮任务块 + 滚动居中(循环) */
|
|
401
|
+
function gotoKwHit() {
|
|
402
|
+
const tl = $('#chatTl');
|
|
403
|
+
if (!tl || !ST.kwHits.length) return;
|
|
404
|
+
ST.kwAt = ((ST.kwAt % ST.kwHits.length) + ST.kwHits.length) % ST.kwHits.length;
|
|
405
|
+
const idx = ST.kwHits[ST.kwAt];
|
|
406
|
+
const pos = $('#kwPos');
|
|
407
|
+
if (pos) pos.textContent = `第 ${ST.kwAt + 1}/${ST.kwHits.length} 处`;
|
|
408
|
+
tl.querySelectorAll('.kw-active').forEach((n) => n.classList.remove('kw-active'));
|
|
409
|
+
const node = tl.querySelector(`[data-task-idx="${idx}"]`);
|
|
410
|
+
if (node) {
|
|
411
|
+
node.classList.add('kw-active');
|
|
412
|
+
node.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function taskHtml(t, botName, idx) {
|
|
417
|
+
const who = ST.nameByUser[t.userId] || short(t.userId, 12);
|
|
418
|
+
const meAva = `<span class="m-ava">${esc(who[0] || '?')}</span>`;
|
|
419
|
+
const tools = t.tools?.length ? `
|
|
420
|
+
<details class="adv" style="margin:6px 0 6px 37px;max-width:min(76%,740px)">
|
|
421
|
+
<summary>⚙ ${t.tools.length} 次工具调用</summary>
|
|
422
|
+
<div class="adv-body">${t.tools.map((x) => `<div class="mono" style="padding:2px 0">${x.ok === false ? '<span style="color:var(--err-tx)">✗</span>' : x.ok === true ? '<span style="color:var(--ok-tx)">✓</span>' : '<span class="dim">·</span>'} ${esc(short(x.tool, 120))}</div>`).join('')}</div>
|
|
423
|
+
</details>` : '';
|
|
424
|
+
const summary = t.result ? `
|
|
425
|
+
<div class="csum">
|
|
426
|
+
<div class="csum-top"><b>任务执行摘要</b>${RESULT_BADGE[t.result.subtype] || `<span class="tag off">${esc(t.result.subtype)}</span>`}</div>
|
|
427
|
+
<div class="csum-stats">
|
|
428
|
+
<span>工具调用 <b>${t.tools?.length || 0}</b> 次</span>
|
|
429
|
+
<span>对话轮次 <b>${t.result.turns || 0}</b> 轮</span>
|
|
430
|
+
${t.result.producedFiles?.length ? `<span>产出文件 <b>${t.result.producedFiles.length}</b> 个</span>` : ''}
|
|
431
|
+
<span>时间 <b>${esc(fmtTime(t.ts))}</b></span>
|
|
432
|
+
</div>
|
|
433
|
+
</div>` : '';
|
|
434
|
+
// AI 回复默认按 Markdown 渲染(回复多为 md 格式);「查看原文」切纯文本
|
|
435
|
+
const replyBubble = t.reply ? `
|
|
436
|
+
<div class="cmsg">
|
|
437
|
+
<span class="m-ava">${esc((botName || 'B')[0])}</span>
|
|
438
|
+
<div class="m-body">
|
|
439
|
+
<div class="m-meta">${esc(botName)} <span>${esc(fmtTime(t.ts))}</span></div>
|
|
440
|
+
<div class="m-bubble">${renderMarkdown(t.reply)}<button class="btn sm ghost m-link" data-md-toggle data-task="${idx}" data-mode="md">查看原文</button></div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>` : '';
|
|
443
|
+
return `
|
|
444
|
+
<div class="chat-task" data-task-idx="${idx}">
|
|
445
|
+
<div class="cmsg me">
|
|
446
|
+
${meAva}
|
|
447
|
+
<div class="m-body">
|
|
448
|
+
<div class="m-meta" style="justify-content:flex-end">${esc(who)} <span>${esc(fmtTime(t.ts))}</span></div>
|
|
449
|
+
<div class="m-bubble">${promptHtml(t.prompt, ST.kw)}</div>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
${tools}
|
|
453
|
+
${replyBubble}
|
|
454
|
+
${summary}
|
|
455
|
+
</div>`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 用户消息渲染:有关键词时做命中高亮(先分段再各自转义,无 XSS);>600 字折叠 */
|
|
459
|
+
function promptHtml(text, kw) {
|
|
460
|
+
const s = String(text ?? '');
|
|
461
|
+
const mark = (str) => {
|
|
462
|
+
if (!kw) return esc(str);
|
|
463
|
+
const re = new RegExp(`(${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
|
464
|
+
return str.split(re).map((p, i) => (i % 2 ? `<mark class="kw-mark">${esc(p)}</mark>` : esc(p))).join('');
|
|
465
|
+
};
|
|
466
|
+
if (s.length > 600) {
|
|
467
|
+
return `${mark(s.slice(0, 600))}…<button class="btn sm ghost m-link" data-more>查看完整内容</button><div class="m-full" hidden>${esc(s)}</div>`;
|
|
468
|
+
}
|
|
469
|
+
return mark(s);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const short = (s, n) => {
|
|
473
|
+
const v = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
474
|
+
return v.length > n ? `${v.slice(0, n)}…` : v;
|
|
475
|
+
};
|
|
476
|
+
const fmtTime = (ts) => {
|
|
477
|
+
const s = String(ts ?? '');
|
|
478
|
+
// ts 为北京时间 ISO(…T21:53:00.000+08:00)——直接取时刻段,避免跨时区换算
|
|
479
|
+
const m = s.match(/T(\d{2}:\d{2})/);
|
|
480
|
+
return m ? m[1] : s;
|
|
481
|
+
};
|