opencode-chat-exporter 1.0.0__py3-none-any.whl
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.
- chat_exporter/__init__.py +2 -0
- chat_exporter/__main__.py +16 -0
- chat_exporter/antigravity.py +599 -0
- chat_exporter/cli.py +194 -0
- chat_exporter/db.py +149 -0
- chat_exporter/gui.py +546 -0
- chat_exporter/render.py +272 -0
- opencode_chat_exporter-1.0.0.dist-info/METADATA +109 -0
- opencode_chat_exporter-1.0.0.dist-info/RECORD +13 -0
- opencode_chat_exporter-1.0.0.dist-info/WHEEL +5 -0
- opencode_chat_exporter-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_chat_exporter-1.0.0.dist-info/licenses/LICENSE +21 -0
- opencode_chat_exporter-1.0.0.dist-info/top_level.txt +1 -0
chat_exporter/gui.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""OpenCode Chat Exporter GUI - a friendly desktop interface.
|
|
2
|
+
|
|
3
|
+
Browse every OpenCode conversation from the local database and export it
|
|
4
|
+
into one continuous, verbatim Markdown transcript - including every command,
|
|
5
|
+
search, file operation and message, exactly as it happened.
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import queue
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import tkinter as tk
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from tkinter import filedialog, messagebox
|
|
17
|
+
|
|
18
|
+
import customtkinter as ctk
|
|
19
|
+
|
|
20
|
+
from .db import DEFAULT_DB, make_db
|
|
21
|
+
from .render import Exporter
|
|
22
|
+
|
|
23
|
+
CONFIG_DIR = Path(os.getenv("APPDATA") or str(Path.home())) / "OpenCodeChatExporter"
|
|
24
|
+
CONFIG_FILE = CONFIG_DIR / "paths.json"
|
|
25
|
+
|
|
26
|
+
ctk.set_appearance_mode("dark")
|
|
27
|
+
|
|
28
|
+
BG = "#0e0e12"
|
|
29
|
+
PANEL = "#17171c"
|
|
30
|
+
SURFACE = "#1e1e26"
|
|
31
|
+
LINE = "#2b2b34"
|
|
32
|
+
ACCENT = "#7aa2ff"
|
|
33
|
+
GREEN = "#86d0a0"
|
|
34
|
+
RED = "#e07080"
|
|
35
|
+
MUTED = "#8b8b99"
|
|
36
|
+
|
|
37
|
+
FONT = "Segoe UI"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChatViewer(tk.Text):
|
|
41
|
+
"""Renders the conversation as colored chat blocks using a fast tag-based Text."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, master, **kw):
|
|
44
|
+
self._container = ctk.CTkFrame(master, fg_color="#101014", corner_radius=8, border_width=1,
|
|
45
|
+
border_color="#2b2b34")
|
|
46
|
+
self._container.grid_columnconfigure(0, weight=1)
|
|
47
|
+
self._container.grid_rowconfigure(0, weight=1)
|
|
48
|
+
kw.setdefault("bg", "#101014")
|
|
49
|
+
kw.setdefault("fg", "#e8e8f0")
|
|
50
|
+
kw.setdefault("insertbackground", "#e8e8f0")
|
|
51
|
+
kw.setdefault("wrap", "word")
|
|
52
|
+
kw.setdefault("padx", 14)
|
|
53
|
+
kw.setdefault("pady", 12)
|
|
54
|
+
kw.setdefault("borderwidth", 0)
|
|
55
|
+
kw.setdefault("highlightthickness", 0)
|
|
56
|
+
kw.setdefault("state", "disabled")
|
|
57
|
+
kw.setdefault("selectbackground", "#2a4a80")
|
|
58
|
+
super().__init__(self._container, **kw)
|
|
59
|
+
self.grid(row=0, column=0, sticky="nsew")
|
|
60
|
+
vsb = ctk.CTkScrollbar(self._container, command=self.yview)
|
|
61
|
+
vsb.grid(row=0, column=1, sticky="ns", padx=(0, 6), pady=6)
|
|
62
|
+
self.configure(yscrollcommand=vsb.set)
|
|
63
|
+
|
|
64
|
+
self.tag_config("user", background="#232f46", foreground="#cfe4ff")
|
|
65
|
+
self.tag_config("user_head", background="#232f46", foreground="#7aa2ff",
|
|
66
|
+
font=(FONT, 12, "bold"))
|
|
67
|
+
self.tag_config("assistant", background="#1c2820", foreground="#dceee0")
|
|
68
|
+
self.tag_config("asst_head", background="#1c2820", foreground="#86d0a0",
|
|
69
|
+
font=(FONT, 12, "bold"))
|
|
70
|
+
self.tag_config("tool", background="#241d2e", foreground="#d6c8ec",
|
|
71
|
+
font=("Consolas", 11))
|
|
72
|
+
self.tag_config("meta", background="#15151a", foreground="#8b8b99",
|
|
73
|
+
font=(FONT, 10, "italic"))
|
|
74
|
+
self.tag_config("error", background="#3a1e22", foreground="#ffb3bd")
|
|
75
|
+
|
|
76
|
+
def clear(self):
|
|
77
|
+
self.configure(state="normal")
|
|
78
|
+
self.delete("1.0", "end")
|
|
79
|
+
self.configure(state="disabled")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def container(self):
|
|
83
|
+
return self._container
|
|
84
|
+
|
|
85
|
+
def add(self, kind: str, text: str):
|
|
86
|
+
if not text:
|
|
87
|
+
return
|
|
88
|
+
kind = kind if kind in ("user", "user_head", "asst_head", "tool", "meta", "error") else "asst"
|
|
89
|
+
self.configure(state="normal")
|
|
90
|
+
self.insert("end", text + "\n\n", kind)
|
|
91
|
+
self.configure(state="disabled")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class App(ctk.CTk):
|
|
95
|
+
def __init__(self):
|
|
96
|
+
super().__init__()
|
|
97
|
+
self.title("OpenCode Chat Exporter")
|
|
98
|
+
self.geometry("1280x860")
|
|
99
|
+
self.minsize(1024, 700)
|
|
100
|
+
self.configure(fg_color=BG)
|
|
101
|
+
|
|
102
|
+
self.db = None
|
|
103
|
+
self.sessions: list[dict] = []
|
|
104
|
+
self.current: dict | None = None
|
|
105
|
+
self.source = "opencode"
|
|
106
|
+
self._paths: dict[str, str] = self._load_paths()
|
|
107
|
+
self.watch_job = None
|
|
108
|
+
self._preview_q: queue.Queue = queue.Queue()
|
|
109
|
+
|
|
110
|
+
self._build_ui()
|
|
111
|
+
self._apply_path()
|
|
112
|
+
self._load_db()
|
|
113
|
+
self.after(200, self.refresh)
|
|
114
|
+
self.after(120, self._drain_preview)
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------- ui build
|
|
117
|
+
def _build_ui(self):
|
|
118
|
+
self.grid_columnconfigure(1, weight=1)
|
|
119
|
+
self.grid_rowconfigure(1, weight=1)
|
|
120
|
+
|
|
121
|
+
# ---------------- header ----------------
|
|
122
|
+
header = ctk.CTkFrame(self, fg_color=PANEL, corner_radius=0, height=64)
|
|
123
|
+
header.grid(row=0, column=0, columnspan=2, sticky="ew")
|
|
124
|
+
header.grid_columnconfigure(2, weight=1)
|
|
125
|
+
|
|
126
|
+
ctk.CTkLabel(header, text="đź’¬ Chat Exporter", font=(FONT, 17, "bold"),
|
|
127
|
+
text_color="#ffffff").grid(row=0, column=0, padx=(16, 8), pady=14)
|
|
128
|
+
|
|
129
|
+
self.status = ctk.CTkLabel(header, text="loading…", text_color=MUTED, font=(FONT, 12))
|
|
130
|
+
self.status.grid(row=0, column=2, sticky="e", padx=16)
|
|
131
|
+
|
|
132
|
+
# ---------------- sidebar ----------------
|
|
133
|
+
self.sidebar = ctk.CTkFrame(self, fg_color=PANEL, corner_radius=0, width=320)
|
|
134
|
+
self.sidebar.grid(row=1, column=0, sticky="nsw", padx=(0, 1))
|
|
135
|
+
self.sidebar.grid_propagate(False)
|
|
136
|
+
self.sidebar.grid_columnconfigure(0, weight=1)
|
|
137
|
+
|
|
138
|
+
# db path row
|
|
139
|
+
seg = ctk.CTkSegmentedButton(self.sidebar, values=["OpenCode", "AntiGravity"],
|
|
140
|
+
command=self._source_changed, font=(FONT, 12))
|
|
141
|
+
seg.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 6))
|
|
142
|
+
seg.set("OpenCode")
|
|
143
|
+
|
|
144
|
+
row = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
|
145
|
+
row.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 4))
|
|
146
|
+
row.grid_columnconfigure(0, weight=1)
|
|
147
|
+
self.db_entry = ctk.CTkEntry(row, height=30, font=(FONT, 11))
|
|
148
|
+
self.db_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6))
|
|
149
|
+
self.db_entry.insert(0, str(DEFAULT_DB))
|
|
150
|
+
ctk.CTkButton(row, text="…", width=30, command=self._browse_db).grid(row=0, column=1)
|
|
151
|
+
ctk.CTkButton(row, text="âźł", width=34, command=self.refresh).grid(row=0, column=2, padx=(6, 0))
|
|
152
|
+
|
|
153
|
+
# search
|
|
154
|
+
self.search_entry = ctk.CTkEntry(self.sidebar, placeholder_text="🔎 search sessions…",
|
|
155
|
+
height=32, font=(FONT, 12))
|
|
156
|
+
self.search_entry.grid(row=2, column=0, sticky="ew", padx=10, pady=6)
|
|
157
|
+
self.search_entry.bind("<KeyRelease>", lambda e: self._filter_sessions())
|
|
158
|
+
|
|
159
|
+
# session list
|
|
160
|
+
self.list_frame = ctk.CTkScrollableFrame(self.sidebar, fg_color="transparent")
|
|
161
|
+
self.list_frame.grid(row=3, column=0, sticky="nsew", padx=6, pady=2)
|
|
162
|
+
self.sidebar.grid_rowconfigure(3, weight=1)
|
|
163
|
+
|
|
164
|
+
# options
|
|
165
|
+
opts = ctk.CTkFrame(self.sidebar, fg_color=SURFACE, corner_radius=10)
|
|
166
|
+
opts.grid(row=4, column=0, sticky="ew", padx=10, pady=(8, 10))
|
|
167
|
+
opts.grid_columnconfigure(0, weight=1)
|
|
168
|
+
|
|
169
|
+
self.ck_reasoning = ctk.CTkCheckBox(opts, text="Include reasoning", font=(FONT, 11))
|
|
170
|
+
self.ck_reasoning.grid(row=0, column=0, sticky="w", padx=8, pady=(8, 2))
|
|
171
|
+
|
|
172
|
+
self.ck_watch = ctk.CTkCheckBox(opts, text="Live watch (auto re-export)", font=(FONT, 11),
|
|
173
|
+
command=self._toggle_watch)
|
|
174
|
+
self.ck_watch.grid(row=1, column=0, sticky="w", padx=8, pady=(2, 2))
|
|
175
|
+
|
|
176
|
+
export_row = ctk.CTkFrame(opts, fg_color="transparent")
|
|
177
|
+
export_row.grid(row=2, column=0, sticky="ew", padx=8, pady=(6, 8))
|
|
178
|
+
export_row.grid_columnconfigure(0, weight=1)
|
|
179
|
+
export_row.grid_columnconfigure(1, weight=1)
|
|
180
|
+
ctk.CTkButton(export_row, text="đź“„ Export session", command=self._export_one,
|
|
181
|
+
fg_color=ACCENT, text_color="#0d1117", font=(FONT, 12, "bold")).grid(
|
|
182
|
+
row=0, column=0, sticky="ew", padx=(0, 4))
|
|
183
|
+
ctk.CTkButton(export_row, text="đź—‚ Export all", command=self._export_all,
|
|
184
|
+
fg_color=ACCENT, text_color="#0d1117", font=(FONT, 12, "bold")).grid(
|
|
185
|
+
row=0, column=1, sticky="ew", padx=(4, 0))
|
|
186
|
+
|
|
187
|
+
# ---------------- main preview ----------------
|
|
188
|
+
self.main = ctk.CTkFrame(self, fg_color=BG, corner_radius=0)
|
|
189
|
+
self.main.grid(row=1, column=1, sticky="nsew")
|
|
190
|
+
self.main.grid_columnconfigure(0, weight=1)
|
|
191
|
+
self.main.grid_rowconfigure(1, weight=1)
|
|
192
|
+
|
|
193
|
+
self.title_label = ctk.CTkLabel(self.main, text="Select a session…", font=(FONT, 16, "bold"),
|
|
194
|
+
text_color="#ffffff", anchor="w")
|
|
195
|
+
self.title_label.grid(row=0, column=0, sticky="ew", padx=18, pady=(14, 2))
|
|
196
|
+
|
|
197
|
+
self.meta_label = ctk.CTkLabel(self.main, text="", text_color=MUTED, font=(FONT, 11), anchor="w")
|
|
198
|
+
self.meta_label.grid(row=1, column=0, sticky="n", padx=18, pady=(0, 4))
|
|
199
|
+
self.main.grid_rowconfigure(1, weight=0)
|
|
200
|
+
|
|
201
|
+
self.viewer = ChatViewer(self.main)
|
|
202
|
+
self.viewer.container.grid(row=2, column=0, sticky="nsew", padx=14, pady=(4, 14))
|
|
203
|
+
self.main.grid_rowconfigure(2, weight=1)
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------- database
|
|
206
|
+
def _load_paths(self) -> dict[str, str]:
|
|
207
|
+
try:
|
|
208
|
+
with open(CONFIG_FILE, encoding="utf-8") as f:
|
|
209
|
+
data = json.load(f)
|
|
210
|
+
return {k: str(v) for k, v in data.items() if k in ("opencode", "antigravity") and v}
|
|
211
|
+
except Exception:
|
|
212
|
+
return {}
|
|
213
|
+
|
|
214
|
+
def _save_paths(self):
|
|
215
|
+
try:
|
|
216
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
218
|
+
json.dump(self._paths, f, ensure_ascii=False, indent=2)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
def _apply_path(self):
|
|
223
|
+
p = self._paths.get(self.source)
|
|
224
|
+
if not p:
|
|
225
|
+
if self.source == "antigravity":
|
|
226
|
+
from .antigravity import DEFAULT_AG_DIR
|
|
227
|
+
p = str(DEFAULT_AG_DIR)
|
|
228
|
+
else:
|
|
229
|
+
p = str(DEFAULT_DB)
|
|
230
|
+
self.db_entry.delete(0, "end")
|
|
231
|
+
self.db_entry.insert(0, p)
|
|
232
|
+
|
|
233
|
+
def _source_changed(self, value: str):
|
|
234
|
+
new = value.lower()
|
|
235
|
+
if new != self.source:
|
|
236
|
+
self._paths[self.source] = self.db_entry.get().strip()
|
|
237
|
+
self._save_paths()
|
|
238
|
+
self.source = new
|
|
239
|
+
self._apply_path()
|
|
240
|
+
self.refresh()
|
|
241
|
+
|
|
242
|
+
def _browse_db(self):
|
|
243
|
+
if self.source == "antigravity":
|
|
244
|
+
p = filedialog.askdirectory(
|
|
245
|
+
title="Select the AntiGravity folder (contains conversations\\ and brain\\)",
|
|
246
|
+
initialdir=self.db_entry.get().strip(),
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
p = filedialog.askopenfilename(
|
|
250
|
+
title="Select opencode.db",
|
|
251
|
+
initialdir=Path(self.db_entry.get()).parent,
|
|
252
|
+
filetypes=[("SQLite database", "*.db"), ("All files", "*.*")],
|
|
253
|
+
)
|
|
254
|
+
if p:
|
|
255
|
+
self.db_entry.delete(0, "end")
|
|
256
|
+
self.db_entry.insert(0, p)
|
|
257
|
+
self._paths[self.source] = p
|
|
258
|
+
self._save_paths()
|
|
259
|
+
self.refresh()
|
|
260
|
+
|
|
261
|
+
def _load_db(self) -> bool:
|
|
262
|
+
path = self.db_entry.get().strip()
|
|
263
|
+
try:
|
|
264
|
+
self.db = make_db(self.source, path)
|
|
265
|
+
return True
|
|
266
|
+
except Exception as e:
|
|
267
|
+
self.db = None
|
|
268
|
+
self._status(f"âš DB error: {e}", RED)
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def refresh(self):
|
|
272
|
+
if not self._load_db():
|
|
273
|
+
self.viewer.clear()
|
|
274
|
+
return
|
|
275
|
+
try:
|
|
276
|
+
self.sessions = self.db.list_sessions()
|
|
277
|
+
except Exception as e:
|
|
278
|
+
self._status(f"âš {e}", RED)
|
|
279
|
+
return
|
|
280
|
+
self._filter_sessions()
|
|
281
|
+
self._status(f"âś“ {len(self.sessions)} sessions", GREEN)
|
|
282
|
+
|
|
283
|
+
def _filter_sessions(self):
|
|
284
|
+
q = self.search_entry.get().strip().lower()
|
|
285
|
+
for w in self.list_frame.winfo_children():
|
|
286
|
+
w.destroy()
|
|
287
|
+
shown = 0
|
|
288
|
+
for s in self.sessions:
|
|
289
|
+
title = (s["title"] or "").lower()
|
|
290
|
+
if q and q not in title and q not in s["id"].lower():
|
|
291
|
+
continue
|
|
292
|
+
self._session_button(s)
|
|
293
|
+
shown += 1
|
|
294
|
+
if not shown:
|
|
295
|
+
ctk.CTkLabel(self.list_frame, text="no matches", text_color=MUTED).pack(pady=20)
|
|
296
|
+
|
|
297
|
+
def _session_button(self, s: dict):
|
|
298
|
+
created = self.db.ts(s["time_created"])
|
|
299
|
+
created_s = created.strftime("%Y-%m-%d %H:%M") if created else ""
|
|
300
|
+
n = s["message_count"]
|
|
301
|
+
active = s["id"] == (self.current or {}).get("id")
|
|
302
|
+
row = ctk.CTkFrame(
|
|
303
|
+
self.list_frame,
|
|
304
|
+
fg_color=ACCENT if active else SURFACE,
|
|
305
|
+
corner_radius=8,
|
|
306
|
+
height=52,
|
|
307
|
+
cursor="hand2",
|
|
308
|
+
)
|
|
309
|
+
row.pack(fill="x", padx=2, pady=2)
|
|
310
|
+
row.pack_propagate(False)
|
|
311
|
+
label = ctk.CTkLabel(
|
|
312
|
+
row,
|
|
313
|
+
text=f"{s['title'] or '(untitled)'}\n{created_s} · {n} messages",
|
|
314
|
+
text_color="#0d1117" if active else "#d9d9e3",
|
|
315
|
+
font=(FONT, 12),
|
|
316
|
+
justify="left",
|
|
317
|
+
anchor="w",
|
|
318
|
+
)
|
|
319
|
+
label.pack(fill="both", expand=True, padx=10, pady=4)
|
|
320
|
+
for w in (row, label):
|
|
321
|
+
w.bind("<Button-1>", lambda e, sid=s["id"]: self._select(sid))
|
|
322
|
+
w.bind("<Enter>", lambda e, r=row, a=active: r.configure(fg_color=ACCENT if a else "#2a2a35"))
|
|
323
|
+
w.bind("<Leave>", lambda e, r=row, a=active: r.configure(fg_color=ACCENT if a else SURFACE))
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------------ select
|
|
326
|
+
def _select(self, session_id: str):
|
|
327
|
+
if self.db is None:
|
|
328
|
+
return
|
|
329
|
+
s = next((x for x in self.sessions if x["id"] == session_id), None)
|
|
330
|
+
if not s:
|
|
331
|
+
return
|
|
332
|
+
self.current = s
|
|
333
|
+
self.title_label.configure(text=s["title"] or "(untitled)")
|
|
334
|
+
meta = []
|
|
335
|
+
if self.source == "antigravity":
|
|
336
|
+
meta.append("source: AntiGravity")
|
|
337
|
+
agent = s.get("agent")
|
|
338
|
+
if agent and agent != "antigravity":
|
|
339
|
+
meta.append(f"agent: {agent}")
|
|
340
|
+
model = s.get("model")
|
|
341
|
+
if isinstance(model, dict) and model.get("modelID"):
|
|
342
|
+
meta.append(f"model: {model['modelID']}")
|
|
343
|
+
created = self.db.ts(s["time_created"])
|
|
344
|
+
if created:
|
|
345
|
+
meta.append(created.strftime("%Y-%m-%d %H:%M"))
|
|
346
|
+
tokens = int(s.get("tokens_input") or 0) + int(s.get("tokens_output") or 0)
|
|
347
|
+
meta.append(f"{tokens:,} tokens" if tokens else "- tokens")
|
|
348
|
+
if s.get("directory"):
|
|
349
|
+
meta.append(str(s["directory"]))
|
|
350
|
+
self.meta_label.configure(text=" · ".join(meta))
|
|
351
|
+
self._filter_sessions()
|
|
352
|
+
self._status(f"loading {s['title']}…", MUTED)
|
|
353
|
+
threading.Thread(target=self._load_preview, args=(s["id"],), daemon=True).start()
|
|
354
|
+
|
|
355
|
+
def _drain_preview(self):
|
|
356
|
+
try:
|
|
357
|
+
while True:
|
|
358
|
+
item = self._preview_q.get_nowait()
|
|
359
|
+
if isinstance(item, tuple) and len(item) == 2 and item[0] == "__popup__":
|
|
360
|
+
title, msg, is_err = item[1]
|
|
361
|
+
if is_err:
|
|
362
|
+
messagebox.showerror(title, msg)
|
|
363
|
+
else:
|
|
364
|
+
messagebox.showinfo(title, msg)
|
|
365
|
+
else:
|
|
366
|
+
self._show_rows(item)
|
|
367
|
+
except queue.Empty:
|
|
368
|
+
pass
|
|
369
|
+
self.after(120, self._drain_preview)
|
|
370
|
+
|
|
371
|
+
def _load_preview(self, session_id: str):
|
|
372
|
+
try:
|
|
373
|
+
messages = self.db.get_session_messages(session_id)
|
|
374
|
+
rows = []
|
|
375
|
+
for m in messages:
|
|
376
|
+
role = m["data"].get("role")
|
|
377
|
+
if role == "user":
|
|
378
|
+
rows.append(("user_head", f"🟢 YOU · {self._fmt(m['time_created'])}"))
|
|
379
|
+
for p in m["parts"]:
|
|
380
|
+
t = p["data"].get("text")
|
|
381
|
+
if p["data"].get("type") == "text" and t:
|
|
382
|
+
rows.append(("user", t))
|
|
383
|
+
elif role == "assistant":
|
|
384
|
+
rows.append(("asst_head", f"🔵 OpenCode · {self._fmt(m['time_created'])}"))
|
|
385
|
+
for p in m["parts"]:
|
|
386
|
+
d = p["data"]
|
|
387
|
+
kind = d.get("type")
|
|
388
|
+
if kind == "text" and d.get("text"):
|
|
389
|
+
rows.append(("assistant", d["text"]))
|
|
390
|
+
elif kind == "tool":
|
|
391
|
+
rows.append(("tool", self._tool_block(d)))
|
|
392
|
+
elif kind == "reasoning" and self.ck_reasoning.get():
|
|
393
|
+
rows.append(("meta", "đź§ reasoning"))
|
|
394
|
+
rows.append(("meta", str(d.get("text", ""))[:4000]))
|
|
395
|
+
elif kind == "file":
|
|
396
|
+
rows.append(("tool", f"📎 {d.get('filename')} ({d.get('mime','')})"))
|
|
397
|
+
if not rows:
|
|
398
|
+
rows = [("meta", "(no messages)")]
|
|
399
|
+
self._preview_q.put(rows)
|
|
400
|
+
except Exception as e:
|
|
401
|
+
self._preview_q.put([("error", f"failed to load: {e}")])
|
|
402
|
+
|
|
403
|
+
def _show_rows(self, rows):
|
|
404
|
+
self.viewer.clear()
|
|
405
|
+
for kind, text in rows:
|
|
406
|
+
self.viewer.add(kind, text)
|
|
407
|
+
self.viewer.see("1.0")
|
|
408
|
+
if self.current:
|
|
409
|
+
self._status(f"âś“ {len(rows)} blocks", GREEN)
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _fmt(ms):
|
|
413
|
+
try:
|
|
414
|
+
import datetime
|
|
415
|
+
return datetime.datetime.fromtimestamp(ms / 1000).strftime("%H:%M:%S")
|
|
416
|
+
except Exception:
|
|
417
|
+
return ""
|
|
418
|
+
|
|
419
|
+
def _tool_block(self, d: dict) -> str:
|
|
420
|
+
tool = d.get("tool") or d.get("name") or "tool"
|
|
421
|
+
state = d.get("state") or {}
|
|
422
|
+
status = state.get("status", "")
|
|
423
|
+
icon = {"completed": "✅", "error": "⛔", "cancelled": "🚫"}.get(status, "⏳")
|
|
424
|
+
lines = [f"{icon} {tool} · {status}"]
|
|
425
|
+
inp = state.get("input") or {}
|
|
426
|
+
title = ""
|
|
427
|
+
for k in ("command", "pattern", "filePath", "path", "url", "query"):
|
|
428
|
+
if inp.get(k) is not None:
|
|
429
|
+
title = str(inp[k])
|
|
430
|
+
break
|
|
431
|
+
if title:
|
|
432
|
+
lines.append(f" in: {title}")
|
|
433
|
+
elif inp:
|
|
434
|
+
lines.append(" in: " + json.dumps(inp, ensure_ascii=False)[:600])
|
|
435
|
+
out = state.get("output")
|
|
436
|
+
if isinstance(out, str) and out.startswith("data:"):
|
|
437
|
+
try:
|
|
438
|
+
out = base64.b64decode(out.split(",", 1)[1]).decode("utf-8", errors="replace")
|
|
439
|
+
except Exception:
|
|
440
|
+
out = "(attachment)"
|
|
441
|
+
if isinstance(out, (dict, list)):
|
|
442
|
+
out = json.dumps(out, ensure_ascii=False)
|
|
443
|
+
if out:
|
|
444
|
+
out = str(out)
|
|
445
|
+
out = out[:4000] + " …" if len(out) > 4000 else out
|
|
446
|
+
lines.append("")
|
|
447
|
+
lines.append(out)
|
|
448
|
+
return "\n".join(lines)
|
|
449
|
+
|
|
450
|
+
# ------------------------------------------------------------------- export
|
|
451
|
+
def _make_exporter(self) -> Exporter:
|
|
452
|
+
return Exporter(
|
|
453
|
+
make_db(self.source, self.db_entry.get().strip()),
|
|
454
|
+
include_reasoning=bool(self.ck_reasoning.get()),
|
|
455
|
+
include_metadata=False,
|
|
456
|
+
output_dir=None,
|
|
457
|
+
max_output=None,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def _export_one(self):
|
|
461
|
+
if not self.current:
|
|
462
|
+
messagebox.showinfo("Chat Exporter", "Select a session first.")
|
|
463
|
+
return
|
|
464
|
+
s = self.current
|
|
465
|
+
default_name = re.sub(r'[\\/:*?"<>|]', "_", s["title"] or s["id"]) + ".md"
|
|
466
|
+
dest = filedialog.asksaveasfilename(
|
|
467
|
+
title="Export session as Markdown",
|
|
468
|
+
defaultextension=".md",
|
|
469
|
+
initialfile=default_name,
|
|
470
|
+
filetypes=[("Markdown", "*.md"), ("Text", "*.txt"), ("All files", "*.*")],
|
|
471
|
+
)
|
|
472
|
+
if not dest:
|
|
473
|
+
return
|
|
474
|
+
self._status("exporting…", MUTED)
|
|
475
|
+
threading.Thread(target=self._do_export, args=(dest, [s["id"]]), daemon=True).start()
|
|
476
|
+
|
|
477
|
+
def _export_all(self):
|
|
478
|
+
folder = filedialog.askdirectory(title="Choose folder to export all sessions into")
|
|
479
|
+
if not folder:
|
|
480
|
+
return
|
|
481
|
+
ids = [s["id"] for s in self.sessions]
|
|
482
|
+
self._status(f"exporting {len(ids)} sessions…", MUTED)
|
|
483
|
+
threading.Thread(target=self._do_export, args=(Path(folder), ids), daemon=True).start()
|
|
484
|
+
|
|
485
|
+
def _do_export(self, dest, ids):
|
|
486
|
+
try:
|
|
487
|
+
exporter = self._make_exporter()
|
|
488
|
+
n = 0
|
|
489
|
+
if isinstance(dest, Path):
|
|
490
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
for sid in ids:
|
|
492
|
+
s = next((x for x in self.sessions if x["id"] == sid), {})
|
|
493
|
+
name = re.sub(r'[\\/:*?"<>|]', "_", s.get("title") or sid) + ".md"
|
|
494
|
+
(dest / name).write_text(exporter.render_session(sid), encoding="utf-8")
|
|
495
|
+
n += 1
|
|
496
|
+
self._preview_q.put([("meta", f"✓ exported {n} sessions → {dest}")])
|
|
497
|
+
self._preview_q.put(("__popup__", ("Done", f"Exported {n} sessions to:\n{dest}", False)))
|
|
498
|
+
else:
|
|
499
|
+
dest = Path(dest)
|
|
500
|
+
dest.write_text(exporter.render_session(ids[0]), encoding="utf-8")
|
|
501
|
+
self._preview_q.put([("meta", f"✓ exported → {dest}")])
|
|
502
|
+
except Exception as e:
|
|
503
|
+
self._preview_q.put([("error", f"âś— export failed: {e}")])
|
|
504
|
+
self._preview_q.put(("__popup__", ("Export failed", str(e), True)))
|
|
505
|
+
|
|
506
|
+
# --------------------------------------------------------------------- misc
|
|
507
|
+
def _toggle_watch(self):
|
|
508
|
+
if self.ck_watch.get():
|
|
509
|
+
self._start_watch()
|
|
510
|
+
else:
|
|
511
|
+
self._stop_watch()
|
|
512
|
+
|
|
513
|
+
def _start_watch(self):
|
|
514
|
+
self._stop_watch()
|
|
515
|
+
self._watch_loop()
|
|
516
|
+
|
|
517
|
+
def _stop_watch(self):
|
|
518
|
+
if self.watch_job:
|
|
519
|
+
self.after_cancel(self.watch_job)
|
|
520
|
+
self.watch_job = None
|
|
521
|
+
|
|
522
|
+
def _watch_loop(self):
|
|
523
|
+
if not self.ck_watch.get():
|
|
524
|
+
return
|
|
525
|
+
try:
|
|
526
|
+
self.refresh()
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
529
|
+
self.watch_job = self.after(8000, self._watch_loop)
|
|
530
|
+
|
|
531
|
+
def _status(self, text: str, color: str = MUTED):
|
|
532
|
+
self.status.configure(text=text, text_color=color)
|
|
533
|
+
|
|
534
|
+
def on_close(self):
|
|
535
|
+
self._stop_watch()
|
|
536
|
+
self.destroy()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main():
|
|
540
|
+
app = App()
|
|
541
|
+
app.protocol("WM_DELETE_WINDOW", app.on_close)
|
|
542
|
+
app.mainloop()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
if __name__ == "__main__":
|
|
546
|
+
main()
|