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