@aiyiran/myclaw 1.1.131 → 1.1.133

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.
@@ -376,22 +376,7 @@
376
376
  }
377
377
 
378
378
  function autoPreloadImages(data) {
379
- if (!data || !data.assets) return;
380
- data.assets.forEach(function (asset) {
381
- if (!isImageAsset(asset)) return;
382
- var url = buildPreviewUrl(data, asset.path);
383
- if (!url || _preloadCache[url]) return;
384
- var pre = new Image();
385
- pre.onload = function () {
386
- console.log('[preload] ✓ ' + asset.path);
387
- };
388
- pre.onerror = function () {
389
- console.warn('[preload] ✗ ' + asset.path);
390
- delete _preloadCache[url]; // 失败的下次可以重试
391
- };
392
- pre.src = url;
393
- _preloadCache[url] = pre; // 占位,防止并发重复触发
394
- });
379
+ // 图片通过 fetch→blob 方式加载,不做 CDN 预加载(CSP 会拦截)
395
380
  }
396
381
 
397
382
  function fetchArtifacts(contentEl) {
@@ -823,9 +808,8 @@
823
808
  'background: #252536',
824
809
  ].join(';');
825
810
 
826
- // img 直接渲染,浏览器自然从上往下加载
811
+ // fetch → blob URL,绕过 CSP img-src 限制
827
812
  var img = document.createElement('img');
828
- img.src = previewUrl;
829
813
  img.alt = asset.name || asset.path;
830
814
  img.style.cssText = [
831
815
  'max-width: 100%',
@@ -836,13 +820,28 @@
836
820
  'position: relative',
837
821
  'z-index: 1',
838
822
  ].join(';');
839
- img.onerror = function () {
823
+
824
+ function showImgError() {
840
825
  img.style.display = 'none';
841
826
  var errMsg = document.createElement('div');
842
827
  errMsg.textContent = '加载失败';
843
828
  errMsg.style.cssText = 'color:#666;font-size:13px;font-family:monospace;';
844
829
  imgArea.appendChild(errMsg);
845
- };
830
+ }
831
+
832
+ var localImgUrl = MYCLAW_API_BASE + '/api/file?path=' + encodeURIComponent(getWorkspaceId() + '/' + asset.path) + '&t=' + Date.now();
833
+ fetch(localImgUrl)
834
+ .then(function (r) {
835
+ if (!r.ok) throw new Error('HTTP ' + r.status);
836
+ return r.blob();
837
+ })
838
+ .then(function (blob) {
839
+ var blobUrl = URL.createObjectURL(blob);
840
+ img.onload = function () { URL.revokeObjectURL(blobUrl); };
841
+ img.onerror = showImgError;
842
+ img.src = blobUrl;
843
+ })
844
+ .catch(showImgError);
846
845
 
847
846
  imgArea.appendChild(img);
848
847
  imgBox.appendChild(imgArea);
@@ -1175,22 +1174,32 @@
1175
1174
  'background: #fff',
1176
1175
  ].join(';');
1177
1176
 
1178
- // 1. 视频和 HTML 走 CDN
1179
- // 2. 其余类型(文本、音频等)走本地 API (避免乱码或 CDN 延迟)
1180
1177
  var VIDEO_EXTS = ['mp4', 'webm', 'mov', 'avi', 'mkv'];
1181
1178
  var isHtml = assetExt === 'html' || assetExt === 'htm';
1182
1179
  var isVideo = VIDEO_EXTS.indexOf(assetExt) !== -1;
1183
1180
 
1184
- if (isHtml || isVideo) {
1185
- iframe.src = previewUrl + '?t=' + Date.now();
1181
+ if (isVideo) {
1182
+ // 视频用 <video> 元素 + 本地 API,支持流式加载和拖拽进度条
1183
+ var video = document.createElement('video');
1184
+ video.controls = true;
1185
+ video.style.cssText = 'flex:1;width:100%;max-height:100%;background:#000;outline:none;';
1186
+ video.src = MYCLAW_API_BASE + '/api/file?path=' + encodeURIComponent(getWorkspaceId() + '/' + asset.path);
1187
+ box.appendChild(video);
1188
+ overlay.appendChild(box);
1189
+ document.body.appendChild(overlay);
1190
+ video.focus();
1186
1191
  } else {
1187
- iframe.src = MYCLAW_API_BASE + '/api/file?path=' + encodeURIComponent(getWorkspaceId() + '/' + asset.path) + '&t=' + Date.now();
1192
+ // HTML CDN;其余类型走本地 API
1193
+ if (isHtml) {
1194
+ iframe.src = previewUrl + '?t=' + Date.now();
1195
+ } else {
1196
+ iframe.src = MYCLAW_API_BASE + '/api/file?path=' + encodeURIComponent(getWorkspaceId() + '/' + asset.path) + '&t=' + Date.now();
1197
+ }
1198
+ box.appendChild(iframe);
1199
+ overlay.appendChild(box);
1200
+ document.body.appendChild(overlay);
1201
+ iframe.focus();
1188
1202
  }
1189
-
1190
- box.appendChild(iframe);
1191
- overlay.appendChild(box);
1192
- document.body.appendChild(overlay);
1193
- iframe.focus();
1194
1203
  return;
1195
1204
  }
1196
1205
 
@@ -1597,7 +1606,11 @@
1597
1606
  });
1598
1607
  });
1599
1608
  statsTimerObserver.observe(document.body, { childList: true });
1609
+ var mTitleVersion = document.createElement('span');
1610
+ mTitleVersion.textContent = 'v1.0';
1611
+ mTitleVersion.style.cssText = 'font-size:10px;color:#555;background:#2a2a3e;border:1px solid #3a3a5a;border-radius:3px;padding:1px 5px;';
1600
1612
  mTitle.appendChild(mTitleText);
1613
+ mTitle.appendChild(mTitleVersion);
1601
1614
  mTitle.appendChild(mTitleTime);
1602
1615
  var mClose = document.createElement('span');
1603
1616
  mClose.textContent = '✕';
@@ -179,9 +179,32 @@ function run(cliArgs) {
179
179
  if (agent.agentDir) agentDirs.set(agent.agentDir, agent.id);
180
180
  }
181
181
 
182
+ // 检测 openclaw 版本,决定写入目标
183
+ // 6.X 使用 node:sqlite (DatabaseSync),5.X 使用 auth-profiles.json
184
+ let DatabaseSync = null;
185
+ try {
186
+ ({ DatabaseSync } = require('node:sqlite'));
187
+ } catch { /* Node < 22,无内置 sqlite,用 JSON 方式 */ }
188
+
189
+ const SQLITE_SCHEMA = `
190
+ CREATE TABLE IF NOT EXISTS auth_profile_store (
191
+ store_key TEXT NOT NULL PRIMARY KEY, store_json TEXT NOT NULL, updated_at INTEGER NOT NULL
192
+ );
193
+ CREATE TABLE IF NOT EXISTS auth_profile_state (
194
+ state_key TEXT NOT NULL PRIMARY KEY, state_json TEXT NOT NULL, updated_at INTEGER NOT NULL
195
+ );`;
196
+
197
+ const sqliteStorePayload = { version: 1, profiles: { "minimax:cn": { type: "api_key", provider: "minimax", key: apiKey } } };
198
+ const sqliteStatePayload = { lastGood: { "minimax": "minimax:cn" } };
199
+
182
200
  let okCount = 0, skipCount = 0;
183
201
 
184
202
  for (const [agentDir, agentId] of agentDirs) {
203
+ if (!fs.existsSync(agentDir)) {
204
+ try { fs.mkdirSync(agentDir, { recursive: true }); } catch { /* ignore */ }
205
+ }
206
+
207
+ // ── 5.X:始终写 auth-profiles.json(兼容旧版) ──
185
208
  const profilesPath = path.join(agentDir, 'auth-profiles.json');
186
209
  let profiles = { version: 1, profiles: {}, lastGood: {} };
187
210
  if (fs.existsSync(profilesPath)) {
@@ -191,14 +214,32 @@ function run(cliArgs) {
191
214
  if (!profiles.lastGood) profiles.lastGood = {};
192
215
  } catch { /* 解析失败用空骨架 */ }
193
216
  }
194
-
195
217
  profiles.profiles["minimax:cn"] = { type: "api_key", provider: "minimax", key: apiKey };
196
218
  profiles.lastGood["minimax"] = "minimax:cn";
219
+ try { fs.writeFileSync(profilesPath, JSON.stringify(profiles, null, 2) + '\n', 'utf8'); } catch { /* ignore */ }
220
+
221
+ // ── 6.X:写入 openclaw-agent.sqlite ──
222
+ let sqliteOk = false;
223
+ if (DatabaseSync) {
224
+ const dbPath = path.join(agentDir, 'openclaw-agent.sqlite');
225
+ try {
226
+ const db = new DatabaseSync(dbPath);
227
+ db.exec('PRAGMA journal_mode = WAL;');
228
+ db.exec(SQLITE_SCHEMA);
229
+ const now = Date.now();
230
+ db.prepare('INSERT OR REPLACE INTO auth_profile_store (store_key, store_json, updated_at) VALUES (?,?,?)')
231
+ .run('primary', JSON.stringify(sqliteStorePayload), now);
232
+ db.prepare('INSERT OR REPLACE INTO auth_profile_state (state_key, state_json, updated_at) VALUES (?,?,?)')
233
+ .run('primary', JSON.stringify(sqliteStatePayload), now);
234
+ db.close();
235
+ sqliteOk = true;
236
+ } catch (err) {
237
+ console.log(' ⚠ ' + agentId + ' (sqlite): ' + err.message);
238
+ }
239
+ }
197
240
 
198
241
  try {
199
- if (!fs.existsSync(agentDir)) fs.mkdirSync(agentDir, { recursive: true });
200
- fs.writeFileSync(profilesPath, JSON.stringify(profiles, null, 2) + '\n', 'utf8');
201
- console.log(' ✅ ' + agentId);
242
+ console.log(' ✅ ' + agentId + (sqliteOk ? ' (json+sqlite)' : ' (json)'));
202
243
  okCount++;
203
244
  } catch (err) {
204
245
  console.log(' ⚠ ' + agentId + ': ' + err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.1.131",
3
+ "version": "1.1.133",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@ description: Extract and render chat history from OpenClaw session URLs. Use whe
10
10
  - **单个 URL/key**:处理一个会话
11
11
  - **逗号分隔**:`url1,url2,url3` 批量处理
12
12
  - **JSON 数组**:`["url1","url2","url3"]` 批量处理
13
+ - **`--scan` 模式**:自动扫描所有 agent 的所有会话,统计表格
13
14
 
14
15
  ## 快速使用(推荐)
15
16
 
@@ -6,42 +6,53 @@ Usage:
6
6
  # 单个会话
7
7
  python3 extract_chat.py <session-url-or-key> [output-dir]
8
8
 
9
- # 多个会话(逗号分隔)
9
+ # 多个会话(逗号分隔 / JSON 数组)
10
10
  python3 extract_chat.py "url1,url2,url3" [output-dir]
11
-
12
- # 多个会话(JSON 数组)
13
11
  python3 extract_chat.py '["url1","url2","url3"]' [output-dir]
14
12
 
13
+ # 扫描所有 agent 的所有会话,统计表格
14
+ python3 extract_chat.py --scan [output-dir]
15
+
15
16
  输出:
16
- - 每个会话生成独立的 <index>-<session-name>.js
17
- - 生成 index.js,包含所有会话的元信息列表
17
+ - 每个会话生成独立的 <agentId>_<sessionName>_<date>_<time>.js
18
+ - 生成 index.js,包含所有会话的元信息列表(含 work_url 字段)
18
19
  - 生成 chat_history.js(向后兼容,指向第一个会话)
20
+
21
+ work_url 字段:
22
+ - 默认为空字符串
23
+ - AI 可后续手动编辑 JS 文件填入学生作品链接
19
24
  """
20
25
 
21
26
  import json
22
27
  import sys
23
28
  import os
24
29
  import re
25
- from urllib.parse import urlparse, parse_qs
30
+ import glob
31
+ from urllib.parse import urlparse, parse_qs, quote
26
32
  from datetime import datetime, timezone, timedelta
27
33
 
28
34
  tz_beijing = timezone(timedelta(hours=8))
29
35
 
30
36
 
31
37
  def parse_time(ts_str):
32
- """Parse ISO timestamp string to Beijing time string."""
33
38
  try:
34
39
  ts_str = ts_str.replace('Z', '')
35
40
  dt = datetime.fromisoformat(ts_str)
36
- dt_utc = dt.replace(tzinfo=timezone.utc)
37
- dt_beijing = dt_utc.astimezone(tz_beijing)
38
- return dt_beijing.strftime('%Y-%m-%d %H:%M:%S')
41
+ return dt.replace(tzinfo=timezone.utc).astimezone(tz_beijing).strftime('%Y-%m-%d %H:%M:%S')
39
42
  except:
40
43
  return None
41
44
 
42
45
 
46
+ def parse_time_for_filename(ts_str):
47
+ try:
48
+ ts_str = ts_str.replace('Z', '')
49
+ dt = datetime.fromisoformat(ts_str)
50
+ return dt.replace(tzinfo=timezone.utc).astimezone(tz_beijing).strftime('%Y%m%d_%H%M')
51
+ except:
52
+ return 'unknown'
53
+
54
+
43
55
  def extract_session_key(url_or_key):
44
- """Extract session key from URL or return as-is if already a key."""
45
56
  if 'session=' in url_or_key:
46
57
  parsed = urlparse(url_or_key)
47
58
  params = parse_qs(parsed.query)
@@ -51,28 +62,18 @@ def extract_session_key(url_or_key):
51
62
 
52
63
 
53
64
  def parse_input(input_str):
54
- """
55
- Parse input into a list of session URLs/keys.
56
- Supports: comma-separated, JSON array, or single value.
57
- """
58
65
  input_str = input_str.strip()
59
-
60
- # Try JSON array
61
66
  if input_str.startswith('['):
62
67
  try:
63
68
  arr = json.loads(input_str)
64
69
  return [item.strip() for item in arr if isinstance(item, str) and item.strip()]
65
70
  except json.JSONDecodeError:
66
71
  pass
67
-
68
- # Comma-separated (but not commas inside URL-encoded params like %3A)
69
- # URLs use %3A for colon, so raw commas are safe as separators
70
72
  parts = [p.strip() for p in input_str.split(',') if p.strip()]
71
73
  return parts if len(parts) > 1 else [input_str]
72
74
 
73
75
 
74
76
  def find_session_file(session_key):
75
- """Find the JSONL file path for a given session key."""
76
77
  parts = session_key.split(':')
77
78
  if len(parts) < 2:
78
79
  return None
@@ -95,9 +96,12 @@ def find_session_file(session_key):
95
96
  return None
96
97
 
97
98
 
98
- def parse_jsonl_to_conversations(jsonl_path):
99
- """Parse JSONL file and build conversation pairs."""
99
+ def count_conversations(jsonl_path):
100
+ """Parse JSONL and return (conversation_count, first_ts, last_ts)."""
100
101
  messages = []
102
+ first_timestamp = None
103
+ last_timestamp = None
104
+
101
105
  with open(jsonl_path, 'r', encoding='utf-8') as f:
102
106
  for line in f:
103
107
  line = line.strip()
@@ -109,134 +113,197 @@ def parse_jsonl_to_conversations(jsonl_path):
109
113
  content = obj['message'].get('content', [])
110
114
  timestamp = obj.get('timestamp', '')
111
115
 
116
+ if timestamp:
117
+ if first_timestamp is None:
118
+ first_timestamp = timestamp
119
+ last_timestamp = timestamp
120
+
112
121
  if isinstance(content, list):
113
- text = ''.join([
114
- c.get('text', '') for c in content if c.get('type') == 'text'
115
- ])
122
+ text = ''.join([c.get('text', '') for c in content if c.get('type') == 'text'])
116
123
  else:
117
124
  text = str(content)
118
125
 
119
126
  messages.append({
120
127
  'role': role,
121
- 'timestamp': timestamp,
122
128
  'text': text.strip(),
123
129
  'has_text': bool(text.strip())
124
130
  })
125
131
 
126
- # Build conversation pairs
132
+ # Count conversation pairs (user messages with at least one AI reply)
127
133
  conversations = []
128
134
  i = 0
129
135
  while i < len(messages):
130
136
  msg = messages[i]
131
137
  if msg['role'] == 'user':
132
138
  user_text = msg['text']
133
- user_time = msg['timestamp']
134
-
135
139
  ai_messages = []
136
140
  j = i + 1
137
141
  while j < len(messages) and messages[j]['role'] != 'user':
138
142
  if messages[j]['role'] == 'assistant':
139
143
  ai_messages.append(messages[j])
140
144
  j += 1
141
-
142
145
  ai_text = '\n\n'.join([m['text'] for m in ai_messages if m['has_text']])
143
- ai_time = ai_messages[-1]['timestamp'] if ai_messages else ''
144
-
145
146
  conversations.append({
146
147
  'user': user_text,
147
- 'user_time': parse_time(user_time) if user_time else '',
148
148
  'ai': ai_text,
149
- 'ai_time': parse_time(ai_time) if ai_time else ''
150
149
  })
151
150
  i += 1
152
151
  else:
153
152
  i += 1
154
153
 
155
- return conversations
154
+ return conversations, first_timestamp, last_timestamp
155
+
156
+
157
+ def parse_jsonl_to_conversations(jsonl_path):
158
+ """Full parse returning conversation pairs with timestamps."""
159
+ conversations, first_ts, last_ts = count_conversations(jsonl_path)
160
+ return conversations, first_ts, last_ts
156
161
 
157
162
 
158
163
  def sanitize_filename(name):
159
- """Make a safe filename from session name."""
160
- # Keep Chinese chars, alphanumerics, replace others with -
161
164
  safe = re.sub(r'[^\w\u4e00-\u9fff\u3400-\u4dbf-]', '-', name)
162
165
  safe = re.sub(r'-+', '-', safe).strip('-')
163
166
  return safe or 'session'
164
167
 
165
168
 
166
- def generate_js(conversations, session_key, output_path):
167
- """Generate JS file from conversations."""
168
- session_name = session_key.split(':')[-1] if ':' in session_key else session_key
169
+ def build_filename(session_key, first_timestamp):
170
+ parts = session_key.split(':')
171
+ agent_id = parts[1] if len(parts) > 1 else 'unknown'
172
+ session_name = parts[2] if len(parts) > 2 else 'main'
173
+ agent_safe = sanitize_filename(agent_id)
174
+ name_safe = sanitize_filename(session_name)
175
+ time_str = parse_time_for_filename(first_timestamp) if first_timestamp else 'unknown'
176
+ return f"{agent_safe}_{name_safe}_{time_str}.js"
177
+
178
+
179
+ def generate_js(conversations, session_key, first_ts, last_ts, output_path):
180
+ parts = session_key.split(':')
181
+ workspace_name = parts[1] if len(parts) > 1 else 'unknown'
182
+ session_name = parts[2] if len(parts) > 2 else 'main'
169
183
 
170
184
  output_data = {
171
- 'session': session_name,
185
+ 'workspace_name': workspace_name,
186
+ 'session_name': session_name,
172
187
  'session_id': session_key,
188
+ 'first_time': parse_time(first_ts) if first_ts else '',
189
+ 'last_time': parse_time(last_ts) if last_ts else '',
173
190
  'total_pairs': len(conversations),
174
- 'initiator': 'session initiator',
191
+ 'work_url': '',
175
192
  'conversations': conversations
176
193
  }
177
-
178
194
  js_content = f'''// Chat History - {session_name}
179
195
  // Session Key: {session_key}
180
196
  // Generated by chat-history-extractor skill
181
197
 
182
198
  const chatData = {json.dumps(output_data, ensure_ascii=False, indent=2)};
183
199
  '''
184
-
185
200
  with open(output_path, 'w', encoding='utf-8') as f:
186
201
  f.write(js_content)
187
-
188
202
  return len(conversations), session_name
189
203
 
190
204
 
191
- def process_one(url_or_key, output_dir, index):
205
+ def session_key_to_url(session_key):
206
+ """Build the claw6 chat URL from a session key."""
207
+ encoded = quote(session_key, safe='')
208
+ return f"https://claw6.kekouen.cn/chat?session={encoded}"
209
+
210
+
211
+ def scan_all_agents():
192
212
  """
193
- Process a single session URL/key.
194
- Returns dict with results or None on failure.
213
+ Scan /root/.openclaw/agents/*/sessions/sessions.json
214
+ Return list of (session_key, agent_id, session_name, jsonl_path) for all sessions.
195
215
  """
216
+ results = []
217
+ base = '/root/.openclaw/agents'
218
+ for agent_dir in sorted(glob.glob(f'{base}/*/sessions/sessions.json')):
219
+ agent_id = agent_dir.split('/agents/')[1].split('/sessions/')[0]
220
+ try:
221
+ with open(agent_dir) as f:
222
+ sessions = json.load(f)
223
+ except:
224
+ continue
225
+ for session_key, meta in sessions.items():
226
+ if not isinstance(meta, dict):
227
+ continue
228
+ session_file = meta.get('sessionFile')
229
+ if not session_file:
230
+ continue
231
+ if not os.path.isabs(session_file):
232
+ session_file = os.path.join(f'{base}/{agent_id}/sessions', session_file)
233
+ if not os.path.exists(session_file):
234
+ continue
235
+ parts = session_key.split(':')
236
+ session_name = parts[2] if len(parts) > 2 else 'main'
237
+ results.append((session_key, agent_id, session_name, session_file))
238
+ return results
239
+
240
+
241
+ def print_summary_table(entries):
242
+ """
243
+ Print the summary table.
244
+ entries: list of dicts with keys: index, session_name, agent_id, total_pairs, work_url, session_key, first_time, last_time
245
+ """
246
+ print(f"\n{'#':>3} {'workspace':<16} {'会话名':<28} {'对话数':>6} {'起始时间':<20} {'最后更新':<20} {'作品链接':<12} {'聊天记录URL'}")
247
+ print(f"{'-'*3} {'-'*16} {'-'*28} {'-'*6} {'-'*20} {'-'*20} {'-'*12} {'-'*50}")
248
+ for e in entries:
249
+ work = e.get('work_url') or '(待填)'
250
+ url = session_key_to_url(e['session_key'])
251
+ name = (e['session_name'] or '')[:26]
252
+ agent = (e.get('agent_id') or '')[:16]
253
+ first_t = (e.get('first_time') or '')[:19]
254
+ last_t = (e.get('last_time') or '')[:19]
255
+ print(f"{e['index']:>3} {agent:<16} {name:<28} {e['total_pairs']:>6} {first_t:<20} {last_t:<20} {work:<12} {url}")
256
+
257
+
258
+ def process_one(url_or_key, output_dir, index):
196
259
  session_key = extract_session_key(url_or_key)
197
- session_short = sanitize_filename(session_key.split(':')[-1] if ':' in session_key else session_key)
260
+ parts = session_key.split(':')
261
+ agent_id = parts[1] if len(parts) > 1 else 'unknown'
262
+ session_name = parts[2] if len(parts) > 2 else 'main'
263
+
198
264
  print(f"\n[{index}] Session key: {session_key}")
199
265
 
200
266
  jsonl_path = find_session_file(session_key)
201
267
  if not jsonl_path:
202
- print(f"[{index}] ERROR: Could not find session file for {session_key}")
268
+ print(f"[{index}] ERROR: Could not find session file")
203
269
  return None
204
-
205
270
  if not os.path.exists(jsonl_path):
206
- print(f"[{index}] ERROR: JSONL file not found: {jsonl_path}")
271
+ print(f"[{index}] ERROR: JSONL not found: {jsonl_path}")
207
272
  return None
208
273
 
209
- print(f"[{index}] JSONL: {jsonl_path}")
210
- conversations = parse_jsonl_to_conversations(jsonl_path)
211
- print(f"[{index}] Found {len(conversations)} conversation pairs")
274
+ conversations, first_ts, last_ts = parse_jsonl_to_conversations(jsonl_path)
275
+ print(f"[{index}] {len(conversations)} pairs")
212
276
 
213
- # Generate per-session JS with index prefix
214
- js_filename = f"{index:02d}-{session_short}.js"
277
+ js_filename = build_filename(session_key, first_ts)
215
278
  js_path = os.path.join(output_dir, js_filename)
216
- count, session_name = generate_js(conversations, session_key, js_path)
217
- print(f"[{index}] Generated: {js_filename} ({count} pairs)")
279
+ count, _ = generate_js(conversations, session_key, first_ts, last_ts, js_path)
280
+ print(f"[{index}] Generated: {js_filename}")
218
281
 
219
282
  return {
220
283
  'index': index,
221
284
  'session_key': session_key,
222
285
  'session_name': session_name,
286
+ 'agent_id': agent_id,
223
287
  'js_file': js_filename,
224
288
  'total_pairs': count,
225
- 'jsonl_path': jsonl_path,
289
+ 'first_time': parse_time(first_ts) if first_ts else '',
290
+ 'last_time': parse_time(last_ts) if last_ts else '',
291
+ 'work_url': '',
226
292
  }
227
293
 
228
294
 
229
295
  def generate_index_js(results, output_dir):
230
- """Generate an index.js listing all sessions."""
231
- sessions = []
232
- for r in results:
233
- sessions.append({
234
- 'index': r['index'],
235
- 'session': r['session_name'],
236
- 'session_id': r['session_key'],
237
- 'js_file': r['js_file'],
238
- 'total_pairs': r['total_pairs'],
239
- })
296
+ sessions = [{
297
+ 'index': r['index'],
298
+ 'workspace_name': r['agent_id'],
299
+ 'session_name': r['session_name'],
300
+ 'session_id': r['session_key'],
301
+ 'js_file': r['js_file'],
302
+ 'total_pairs': r['total_pairs'],
303
+ 'first_time': r['first_time'],
304
+ 'last_time': r['last_time'],
305
+ 'work_url': r['work_url'],
306
+ } for r in results]
240
307
 
241
308
  index_content = f'''// Chat History Index - {len(sessions)} session(s)
242
309
  // Generated by chat-history-extractor skill
@@ -249,24 +316,82 @@ const chatIndex = {json.dumps(sessions, ensure_ascii=False, indent=2)};
249
316
  print(f"\nIndex: {index_path} ({len(sessions)} sessions)")
250
317
 
251
318
 
319
+ def run_scan(output_dir=None):
320
+ """Scan all agents, count conversations, show table. Optionally generate JS files."""
321
+ all_sessions = scan_all_agents()
322
+ print(f"Found {len(all_sessions)} session(s) across all agents.\n")
323
+
324
+ entries = []
325
+ for i, (session_key, agent_id, session_name, jsonl_path) in enumerate(all_sessions, 1):
326
+ try:
327
+ convs, first_ts, last_ts = count_conversations(jsonl_path)
328
+ entries.append({
329
+ 'index': i,
330
+ 'session_key': session_key,
331
+ 'session_name': session_name,
332
+ 'agent_id': agent_id,
333
+ 'total_pairs': len(convs),
334
+ 'work_url': '',
335
+ 'first_time': parse_time(first_ts) if first_ts else '',
336
+ 'last_time': parse_time(last_ts) if last_ts else '',
337
+ '_jsonl_path': jsonl_path,
338
+ '_first_ts': first_ts,
339
+ })
340
+ except Exception as e:
341
+ print(f" [SKIP] {session_key}: {e}")
342
+
343
+ print_summary_table(entries)
344
+
345
+ # Generate JS files if output_dir specified
346
+ if output_dir:
347
+ os.makedirs(output_dir, exist_ok=True)
348
+ results = []
349
+ for e in entries:
350
+ js_filename = build_filename(e['session_key'], e['_first_ts'])
351
+ js_path = os.path.join(output_dir, js_filename)
352
+ convs, e_first, e_last = count_conversations(e['_jsonl_path'])
353
+ generate_js(convs, e['session_key'], e_first, e_last, js_path)
354
+ results.append({**e, 'js_file': js_filename})
355
+
356
+ if len(results) > 1:
357
+ generate_index_js(results, output_dir)
358
+
359
+ # Backward compat
360
+ first_js = os.path.join(output_dir, results[0]['js_file'])
361
+ compat_js = os.path.join(output_dir, 'chat_history.js')
362
+ if first_js != compat_js:
363
+ import shutil
364
+ shutil.copy2(first_js, compat_js)
365
+
366
+ total_pairs = sum(e['total_pairs'] for e in entries)
367
+ print(f"\n合计:{len(entries)} 个会话,{total_pairs} 对对话。")
368
+ return entries
369
+
370
+
252
371
  def main():
253
372
  if len(sys.argv) < 2:
254
373
  print("Usage:")
255
374
  print(" python3 extract_chat.py <url-or-key> [output-dir]")
256
375
  print(" python3 extract_chat.py 'url1,url2,url3' [output-dir]")
257
376
  print(' python3 extract_chat.py \'["url1","url2"]\' [output-dir]')
377
+ print(" python3 extract_chat.py --scan [output-dir]")
258
378
  sys.exit(1)
259
379
 
260
- raw_input = sys.argv[1]
261
- output_dir = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
380
+ arg1 = sys.argv[1]
381
+
382
+ # --scan mode
383
+ if arg1 == '--scan':
384
+ output_dir = sys.argv[2] if len(sys.argv) > 2 else None
385
+ run_scan(output_dir)
386
+ return
262
387
 
263
- # Parse input into list
264
- items = parse_input(raw_input)
388
+ # Normal mode
389
+ output_dir = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
390
+ items = parse_input(arg1)
265
391
  print(f"Input: {len(items)} session(s)")
266
392
 
267
393
  os.makedirs(output_dir, exist_ok=True)
268
394
 
269
- # Process each session
270
395
  results = []
271
396
  for i, item in enumerate(items, 1):
272
397
  r = process_one(item, output_dir, i)
@@ -277,11 +402,9 @@ def main():
277
402
  print("\nERROR: No sessions processed successfully.")
278
403
  sys.exit(1)
279
404
 
280
- # Generate index.js if multiple sessions
281
405
  if len(results) > 1:
282
406
  generate_index_js(results, output_dir)
283
407
 
284
- # Backward compat: copy first session as chat_history.js
285
408
  first_js = os.path.join(output_dir, results[0]['js_file'])
286
409
  compat_js = os.path.join(output_dir, 'chat_history.js')
287
410
  if first_js != compat_js:
@@ -289,11 +412,10 @@ def main():
289
412
  shutil.copy2(first_js, compat_js)
290
413
  print(f"Compat: chat_history.js -> {results[0]['js_file']}")
291
414
 
292
- # Summary
293
- print(f"\n{'='*50}")
294
- print(f"Done! {len(results)}/{len(items)} session(s) processed.")
295
- for r in results:
296
- print(f" [{r['index']}] {r['session_name']} ({r['total_pairs']} pairs) -> {r['js_file']}")
415
+ # Summary table
416
+ print_summary_table(results)
417
+ total_pairs = sum(r['total_pairs'] for r in results)
418
+ print(f"\n合计:{len(results)} 个会话,{total_pairs} 对对话。")
297
419
 
298
420
 
299
421
  if __name__ == '__main__':