chuckfinca Claude Opus 4.6 (1M context) commited on
Commit
d52caba
·
1 Parent(s): 1ed0433

Rewrite explorer with Docker SDK, streaming, citations, custom UI

Browse files

Switch from Gradio SDK to Docker SDK with custom HTML frontend.
Add streaming answers, citation processing (from harness), source
list, trace toggle, file upload with drag-and-drop, session cost
tracking. Shared chat-ui.js/css fetched at build time from
harness-apps-shared repo. Pin harness to acfd4f2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (5) hide show
  1. Dockerfile +20 -0
  2. README.md +1 -4
  3. app.py +413 -225
  4. requirements.txt +1 -1
  5. static/.gitkeep +1 -0
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*
4
+
5
+ RUN useradd -m -u 1000 user
6
+ WORKDIR /app
7
+
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt "gradio>=5.12,<6"
10
+
11
+ RUN mkdir -p /app/static && \
12
+ curl -sL https://raw.githubusercontent.com/chuckfinca/harness-apps-shared/main/chat-ui.js -o /app/static/chat-ui.js && \
13
+ curl -sL https://raw.githubusercontent.com/chuckfinca/harness-apps-shared/main/chat-ui.css -o /app/static/chat-ui.css
14
+
15
+ COPY . .
16
+
17
+ USER user
18
+ EXPOSE 7860
19
+
20
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -3,11 +3,8 @@ title: Document Explorer
3
  emoji: 📄
4
  colorFrom: blue
5
  colorTo: green
6
- sdk: gradio
7
- sdk_version: "6.9.0"
8
- app_file: app.py
9
  pinned: false
10
- python_version: "3.12"
11
  license: mit
12
  ---
13
 
 
3
  emoji: 📄
4
  colorFrom: blue
5
  colorTo: green
6
+ sdk: docker
 
 
7
  pinned: false
 
8
  license: mit
9
  ---
10
 
app.py CHANGED
@@ -1,10 +1,7 @@
1
- """Web interface for exploring document workspaces with an LLM agent.
2
 
3
- Usage:
4
- python app.py
5
-
6
- Requires LH_MODEL and LH_ACCESS_TOKEN in .env or environment.
7
- Uses E2B sandboxes for code execution (no Docker required).
8
  """
9
 
10
  from __future__ import annotations
@@ -13,6 +10,7 @@ import json
13
  import os
14
  import tempfile
15
  import time
 
16
  from dataclasses import asdict
17
  from datetime import datetime, timezone
18
  from pathlib import Path
@@ -23,10 +21,11 @@ from dotenv import load_dotenv
23
  from huggingface_hub import HfApi
24
 
25
  from llm_harness.agent import run_agent_loop
 
26
  from llm_harness.prompt import build_system_prompt
27
  from llm_harness.tools import TOOL_DEFINITIONS
28
  from llm_harness.trace_viewer import render_trace
29
- from llm_harness.types import Message, ToolCallEvent, ToolResultEvent
30
 
31
  from sandbox_e2b import run_python as e2b_run_python
32
 
@@ -40,18 +39,25 @@ MAX_SESSION_COST = float(os.environ.get("LH_MAX_SESSION_COST", "0.50"))
40
  HF_TRACES_REPO = os.environ.get("HF_TRACES_REPO", "")
41
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
42
 
 
 
43
  hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
44
 
45
 
 
 
 
 
 
46
  def _slugify(text: str, max_len: int = 50) -> str:
47
  slug = text.lower().replace(" ", "-")
48
  slug = "".join(c for c in slug if c.isalnum() or c == "-")
49
  return slug[:max_len].rstrip("-")
50
 
51
 
52
- def upload_trace(result: dict) -> str | None:
53
  if not hf_api or not HF_TRACES_REPO:
54
- return None
55
  timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
56
  question_slug = _slugify(result.get("question", ""))
57
  filename = f"{timestamp}_{question_slug}.json" if question_slug else f"{timestamp}.json"
@@ -63,36 +69,8 @@ def upload_trace(result: dict) -> str | None:
63
  repo_id=HF_TRACES_REPO,
64
  repo_type="dataset",
65
  )
66
- return filename
67
  except Exception as exc:
68
  print(f"WARNING: trace upload failed: {exc}")
69
- return None
70
-
71
-
72
- def list_traces() -> list[str]:
73
- if not hf_api or not HF_TRACES_REPO:
74
- return []
75
- try:
76
- files = hf_api.list_repo_files(HF_TRACES_REPO, repo_type="dataset")
77
- traces = sorted(
78
- [f for f in files if f.endswith(".json")],
79
- reverse=True,
80
- )
81
- return traces
82
- except Exception:
83
- return []
84
-
85
-
86
- def fetch_trace(filename: str) -> dict | None:
87
- if not hf_api or not HF_TRACES_REPO or not filename:
88
- return None
89
- try:
90
- path = hf_api.hf_hub_download(
91
- HF_TRACES_REPO, filename, repo_type="dataset"
92
- )
93
- return json.loads(Path(path).read_text())
94
- except Exception:
95
- return None
96
 
97
 
98
  def save_uploaded_files(files: list[str]) -> Path:
@@ -104,56 +82,78 @@ def save_uploaded_files(files: list[str]) -> Path:
104
 
105
 
106
  def format_stats(trace: object) -> str:
107
- cost_str = f"${trace.cost:.4f}" if trace.cost else "n/a"
108
  cached = trace.cached_tokens
109
  cache_str = f" ({cached} cached)" if cached else ""
110
- scratchpad = len(trace.scratch_files)
111
  model_name = trace.model.split("/")[-1] if trace.model else ""
112
- stats = (
113
- f"*{model_name}"
114
- f" · {trace.prompt_tokens + trace.completion_tokens:,} tokens{cache_str}"
115
- f" · {len(trace.tool_calls)} tool calls"
116
- f" · {trace.wall_time_s:.1f}s"
117
- f" · {cost_str}"
118
- )
119
- if scratchpad:
120
- stats += f" · {scratchpad} scratchpad files"
121
- return stats + "*"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
 
124
- def chat(
125
- message: str,
126
- files: list[str] | None,
 
 
 
 
127
  workspace_path: str,
128
  scratch_path: str,
129
  session_cost: float,
130
- token: str = "",
131
- ):
132
- empty = ("", "", workspace_path, scratch_path, session_cost, None)
133
-
134
  if ACCESS_TOKEN and token != ACCESS_TOKEN:
135
- yield ("Invalid access token.", *empty[1:])
136
  return
137
 
138
  if not MODEL:
139
- yield ("Error: LH_MODEL not set.", *empty[1:])
140
  return
141
 
142
  if session_cost >= MAX_SESSION_COST:
143
- yield (
144
- f"Session cost limit reached (${session_cost:.2f} / "
145
- f"${MAX_SESSION_COST:.2f}). Start a new session.",
146
- *empty[1:],
147
- )
148
  return
149
 
150
- # Set up workspace from uploaded files (first message only)
151
  workspace = Path(workspace_path) if workspace_path else None
152
  if files and not workspace:
153
  workspace = save_uploaded_files(files)
154
- workspace_path = str(workspace)
155
 
156
- # Set up scratchpad (once per session)
 
 
 
157
  if not scratch_path:
158
  scratch_path = tempfile.mkdtemp(prefix="lh-scratch-")
159
  scratch_dir = Path(scratch_path)
@@ -161,10 +161,9 @@ def chat(
161
  system_prompt = build_system_prompt(base_prompt="", workspace=workspace)
162
  messages: list[Message] = [
163
  {"role": "system", "content": system_prompt},
164
- {"role": "user", "content": message},
165
  ]
166
 
167
- # Run agent loop with E2B sandbox
168
  start = time.monotonic()
169
  agent_run = run_agent_loop(
170
  model=MODEL,
@@ -174,185 +173,374 @@ def chat(
174
  workspace=workspace,
175
  scratch_dir=scratch_dir,
176
  sandbox_fn=e2b_run_python,
 
177
  )
178
 
179
  tool_call_count = 0
180
  try:
181
  for event in agent_run:
182
- if isinstance(event, ToolCallEvent):
 
 
183
  tool_call_count += 1
184
- status = f"*Exploring documents ({tool_call_count} tool calls)...*"
185
- yield status, "", workspace_path, scratch_path, session_cost, None
186
- elif isinstance(event, ToolResultEvent):
187
- continue
188
- else:
189
- cost = agent_run.trace.cost or 0
190
- session_cost += cost
191
  except Exception as exc:
192
- yield (
193
- f"Error: {exc}",
194
- "",
195
- workspace_path,
196
- scratch_path,
197
- session_cost,
198
- None,
199
- )
200
  return
201
 
202
  trace = agent_run.trace
203
  trace.wall_time_s = round(time.monotonic() - start, 2)
204
- answer = trace.answer or "(no answer)"
205
- stats = format_stats(trace)
206
- result = {"question": message, "passed": True, "assertions": {}, "trace": asdict(trace)}
207
- trace_filename = upload_trace(result)
 
 
 
 
 
 
 
 
208
  trace_html = render_trace(result, max_chars=2000)
209
 
210
- yield (
211
- f"{answer}\n\n---\n{stats}",
212
- trace_html,
213
- workspace_path,
214
- scratch_path,
215
- session_cost,
216
- trace_filename,
217
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
 
220
  def build_app() -> gr.Blocks:
221
- with gr.Blocks(title="Document Explorer", theme=gr.themes.Soft()) as demo:
222
- gr.Markdown("# Document Explorer")
223
-
224
- with gr.Tabs():
225
- with gr.Tab("Chat"):
226
- gr.Markdown(
227
- "Upload text or CSV files, then ask questions. "
228
- "The model explores your documents by writing and running Python code."
229
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
- workspace_state = gr.State("")
232
- scratch_state = gr.State("")
233
- cost_state = gr.State(0.0)
234
- session_traces_state = gr.State([]) # filenames uploaded this session
235
-
236
- with gr.Row():
237
- token_input = gr.Textbox(
238
- placeholder="Access token",
239
- label="Access Token",
240
- type="password",
241
- scale=1,
242
- )
243
-
244
- with gr.Accordion("Upload documents", open=True):
245
- file_upload = gr.File(
246
- label="Text, CSV, Markdown, or JSON files",
247
- file_count="multiple",
248
- file_types=[".txt", ".csv", ".md", ".json"],
249
- )
250
-
251
- chatbot = gr.Chatbot(height=500)
252
- msg = gr.Textbox(
253
- placeholder="Ask a question about your documents...",
254
- label="",
255
- show_label=False,
256
- )
257
 
258
- with gr.Accordion("Trace", open=False, visible=False) as trace_accordion:
259
- trace_display = gr.HTML("")
260
-
261
- def respond(
262
- message, history, files, workspace_path, scratch_path,
263
- session_cost, session_traces, token,
264
- ):
265
- history = history or []
266
- history.append({"role": "user", "content": message})
267
-
268
- for response, trace_html, wp, sp, sc, trace_file in chat(
269
- message, files, workspace_path, scratch_path, session_cost, token
270
- ):
271
- if trace_file:
272
- session_traces = [*session_traces, trace_file]
273
- history_with_response = [
274
- *history,
275
- {"role": "assistant", "content": response},
276
- ]
277
- accordion = gr.Accordion(visible=bool(trace_html))
278
- yield (
279
- history_with_response, "", trace_html, accordion,
280
- wp, sp, sc, session_traces,
281
- )
282
-
283
- msg.submit(
284
- respond,
285
- inputs=[
286
- msg,
287
- chatbot,
288
- file_upload,
289
- workspace_state,
290
- scratch_state,
291
- cost_state,
292
- session_traces_state,
293
- token_input,
294
- ],
295
- outputs=[
296
- chatbot,
297
- msg,
298
- trace_display,
299
- trace_accordion,
300
- workspace_state,
301
- scratch_state,
302
- cost_state,
303
- session_traces_state,
304
- ],
305
- )
306
 
307
- with gr.Tab("Traces") as traces_tab:
308
- admin_state = gr.State(False)
309
- trace_dropdown = gr.Dropdown(
310
- choices=[],
311
- label="Select trace",
312
- )
313
- refresh_btn = gr.Button("Refresh")
314
- trace_viewer = gr.HTML("")
315
-
316
- def check_admin(request: gr.Request):
317
- token = request.query_params.get("admin", "")
318
- return ADMIN_TOKEN and token == ADMIN_TOKEN
319
-
320
- def show_trace(filename):
321
- result = fetch_trace(filename)
322
- if not result:
323
- return ""
324
- return render_trace(result, max_chars=5000)
325
-
326
- def refresh_traces(session_traces, is_admin):
327
- if is_admin:
328
- filenames = list_traces()
329
- else:
330
- filenames = sorted(session_traces, reverse=True)
331
- return gr.Dropdown(
332
- choices=filenames,
333
- value=filenames[0] if filenames else None,
334
- )
335
-
336
- demo.load(
337
- check_admin,
338
- outputs=[admin_state],
339
- )
340
 
341
- trace_dropdown.change(
342
- show_trace,
343
- inputs=[trace_dropdown],
344
- outputs=[trace_viewer],
 
 
345
  )
346
- refresh_btn.click(
347
- refresh_traces,
348
- inputs=[session_traces_state, admin_state],
349
- outputs=[trace_dropdown],
350
  )
351
- traces_tab.select(
352
- refresh_traces,
353
- inputs=[session_traces_state, admin_state],
354
- outputs=[trace_dropdown],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
  return demo
358
 
@@ -362,4 +550,4 @@ if __name__ == "__main__":
362
  print("WARNING: LH_ACCESS_TOKEN not set — app is unprotected")
363
 
364
  app = build_app()
365
- app.launch()
 
1
+ """Document Explorer upload documents, ask questions with cited answers.
2
 
3
+ The LLM explores documents by writing Python code. No training, no vector DB.
4
+ Built on a-simple-llm-harness.
 
 
 
5
  """
6
 
7
  from __future__ import annotations
 
10
  import os
11
  import tempfile
12
  import time
13
+ from collections.abc import Generator
14
  from dataclasses import asdict
15
  from datetime import datetime, timezone
16
  from pathlib import Path
 
21
  from huggingface_hub import HfApi
22
 
23
  from llm_harness.agent import run_agent_loop
24
+ from llm_harness.citations import process_citations, superscript
25
  from llm_harness.prompt import build_system_prompt
26
  from llm_harness.tools import TOOL_DEFINITIONS
27
  from llm_harness.trace_viewer import render_trace
28
+ from llm_harness.types import Message, TextDeltaEvent, ToolCallEvent, ToolResultEvent
29
 
30
  from sandbox_e2b import run_python as e2b_run_python
31
 
 
39
  HF_TRACES_REPO = os.environ.get("HF_TRACES_REPO", "")
40
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
41
 
42
+ SOURCE = "prod" if os.environ.get("SPACE_ID") else "dev"
43
+
44
  hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
45
 
46
 
47
+ # ---------------------------------------------------------------------------
48
+ # Helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
  def _slugify(text: str, max_len: int = 50) -> str:
53
  slug = text.lower().replace(" ", "-")
54
  slug = "".join(c for c in slug if c.isalnum() or c == "-")
55
  return slug[:max_len].rstrip("-")
56
 
57
 
58
+ def upload_trace(result: dict) -> None:
59
  if not hf_api or not HF_TRACES_REPO:
60
+ return
61
  timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
62
  question_slug = _slugify(result.get("question", ""))
63
  filename = f"{timestamp}_{question_slug}.json" if question_slug else f"{timestamp}.json"
 
69
  repo_id=HF_TRACES_REPO,
70
  repo_type="dataset",
71
  )
 
72
  except Exception as exc:
73
  print(f"WARNING: trace upload failed: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
 
76
  def save_uploaded_files(files: list[str]) -> Path:
 
82
 
83
 
84
  def format_stats(trace: object) -> str:
 
85
  cached = trace.cached_tokens
86
  cache_str = f" ({cached} cached)" if cached else ""
 
87
  model_name = trace.model.split("/")[-1] if trace.model else ""
88
+ parts = [
89
+ model_name,
90
+ f"{trace.prompt_tokens + trace.completion_tokens:,} tokens{cache_str}",
91
+ f"{len(trace.tool_calls)} tool calls",
92
+ f"{trace.wall_time_s:.1f}s",
93
+ ]
94
+ if trace.cost:
95
+ parts.append(f"${trace.cost:.4f}")
96
+ return " · ".join(parts)
97
+
98
+
99
+ def format_stats_from_trace(trace: dict) -> str:
100
+ cached = trace.get("cached_tokens", 0)
101
+ cache_str = f" ({cached} cached)" if cached else ""
102
+ model = trace.get("model", "")
103
+ model_name = model.split("/")[-1] if model else ""
104
+ prompt = trace.get("prompt_tokens", 0)
105
+ completion = trace.get("completion_tokens", 0)
106
+ tool_calls = trace.get("tool_calls", [])
107
+ wall = trace.get("wall_time_s", 0)
108
+ cost = trace.get("cost")
109
+ parts = [
110
+ model_name,
111
+ f"{prompt + completion:,} tokens{cache_str}",
112
+ f"{len(tool_calls)} tool calls",
113
+ f"{wall:.1f}s",
114
+ ]
115
+ if cost:
116
+ parts.append(f"${cost:.4f}")
117
+ return " · ".join(parts)
118
 
119
 
120
+ # ---------------------------------------------------------------------------
121
+ # Streaming question handler
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def stream_question(
126
+ question: str,
127
  workspace_path: str,
128
  scratch_path: str,
129
  session_cost: float,
130
+ token: str,
131
+ files: list[str] | None = None,
132
+ ) -> Generator[str, None, None]:
133
+ """Streaming API — yields JSON event strings."""
134
  if ACCESS_TOKEN and token != ACCESS_TOKEN:
135
+ yield json.dumps({"type": "error", "error": "Invalid access token."})
136
  return
137
 
138
  if not MODEL:
139
+ yield json.dumps({"type": "error", "error": "LH_MODEL not set."})
140
  return
141
 
142
  if session_cost >= MAX_SESSION_COST:
143
+ yield json.dumps({
144
+ "type": "error",
145
+ "error": f"Session cost limit reached (${session_cost:.2f} / ${MAX_SESSION_COST:.2f}).",
146
+ })
 
147
  return
148
 
 
149
  workspace = Path(workspace_path) if workspace_path else None
150
  if files and not workspace:
151
  workspace = save_uploaded_files(files)
 
152
 
153
+ if not workspace:
154
+ yield json.dumps({"type": "error", "error": "No documents uploaded."})
155
+ return
156
+
157
  if not scratch_path:
158
  scratch_path = tempfile.mkdtemp(prefix="lh-scratch-")
159
  scratch_dir = Path(scratch_path)
 
161
  system_prompt = build_system_prompt(base_prompt="", workspace=workspace)
162
  messages: list[Message] = [
163
  {"role": "system", "content": system_prompt},
164
+ {"role": "user", "content": question},
165
  ]
166
 
 
167
  start = time.monotonic()
168
  agent_run = run_agent_loop(
169
  model=MODEL,
 
173
  workspace=workspace,
174
  scratch_dir=scratch_dir,
175
  sandbox_fn=e2b_run_python,
176
+ stream=True,
177
  )
178
 
179
  tool_call_count = 0
180
  try:
181
  for event in agent_run:
182
+ if isinstance(event, TextDeltaEvent):
183
+ yield json.dumps({"type": "delta", "content": event.content})
184
+ elif isinstance(event, ToolCallEvent):
185
  tool_call_count += 1
186
+ yield json.dumps({"type": "tool_call", "count": tool_call_count, "name": event.name})
 
 
 
 
 
 
187
  except Exception as exc:
188
+ yield json.dumps({"type": "error", "error": str(exc)})
 
 
 
 
 
 
 
189
  return
190
 
191
  trace = agent_run.trace
192
  trace.wall_time_s = round(time.monotonic() - start, 2)
193
+
194
+ clean_answer, sources = process_citations(trace.answer or "", workspace)
195
+
196
+ result = {
197
+ "question": question,
198
+ "source": SOURCE,
199
+ "passed": True,
200
+ "assertions": {},
201
+ "trace": asdict(trace),
202
+ "citations": sources,
203
+ }
204
+ upload_trace(result)
205
  trace_html = render_trace(result, max_chars=2000)
206
 
207
+ yield json.dumps({
208
+ "type": "done",
209
+ "answer": clean_answer,
210
+ "sources": sources,
211
+ "stats": format_stats(trace),
212
+ "trace_html": trace_html,
213
+ "workspace_path": str(workspace),
214
+ "scratch_path": str(scratch_dir),
215
+ "session_cost": session_cost + (trace.cost or 0),
216
+ })
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Gradio app
221
+ # ---------------------------------------------------------------------------
222
+
223
+ CUSTOM_HTML = """
224
+ <div id="explorer-app">
225
+ <div id="setup-panel">
226
+ <div class="setup-field">
227
+ <label for="token-input">Access Token</label>
228
+ <input type="password" id="token-input" placeholder="Enter your access token" />
229
+ </div>
230
+ <div class="setup-field">
231
+ <label>Upload Documents</label>
232
+ <div id="drop-zone" class="drop-zone">
233
+ <p>Drag and drop files here, or <label for="file-input" class="file-label">browse</label></p>
234
+ <input type="file" id="file-input" multiple accept=".txt,.csv,.md,.json,.pdf" style="display:none" />
235
+ <div id="file-list"></div>
236
+ </div>
237
+ </div>
238
+ <button id="start-btn" disabled>Start exploring</button>
239
+ </div>
240
+
241
+ <div id="chat-panel" style="display:none">
242
+ <div class="chat-container">
243
+ <div class="chat-history" id="chat-history"></div>
244
+ <div class="chat-input-wrapper">
245
+ <input type="text" id="chat-input" class="chat-input" placeholder="Ask a question about your documents..." autocomplete="off" />
246
+ </div>
247
+ <div class="chat-stats" id="session-cost"></div>
248
+ </div>
249
+ </div>
250
+ </div>
251
+
252
+ <link rel="stylesheet" href="/file=static/chat-ui.css" />
253
+ <script src="/file=static/chat-ui.js"></script>
254
+ <style>
255
+ #explorer-app { font-family: var(--font-family, 'Inter', system-ui, sans-serif); max-width: 800px; margin: 0 auto; }
256
+ #setup-panel { padding: 24px 0; }
257
+ .setup-field { margin-bottom: 16px; }
258
+ .setup-field label { display: block; font-size: 13px; color: #6B7280; margin-bottom: 4px; }
259
+ #token-input { width: 100%; padding: 8px 12px; border: 1px solid #E5E7EB; border-radius: 4px; font-family: inherit; font-size: 14px; }
260
+ #token-input:focus { outline: none; border-color: #4682B4; }
261
+ .drop-zone { border: 2px dashed #E5E7EB; border-radius: 8px; padding: 32px; text-align: center; color: #9CA3AF; cursor: pointer; transition: border-color 0.2s; }
262
+ .drop-zone.drag-over { border-color: #4682B4; background: #f0f7ff; }
263
+ .drop-zone p { margin: 0; }
264
+ .file-label { color: #4682B4; cursor: pointer; text-decoration: underline; }
265
+ #file-list { margin-top: 8px; font-size: 13px; color: #374151; text-align: left; }
266
+ #file-list div { padding: 2px 0; }
267
+ #start-btn { width: 100%; padding: 10px; background: #4682B4; color: white; border: none; border-radius: 4px; font-family: inherit; font-size: 14px; cursor: pointer; margin-top: 8px; }
268
+ #start-btn:disabled { background: #D1D5DB; cursor: default; }
269
+ #start-btn:not(:disabled):hover { background: #3a6f9a; }
270
+ #chat-panel { padding-top: 8px; }
271
+ </style>
272
+ <script>
273
+ (function() {
274
+ var API_BASE = window.location.origin;
275
+ var tokenInput = document.getElementById('token-input');
276
+ var fileInput = document.getElementById('file-input');
277
+ var dropZone = document.getElementById('drop-zone');
278
+ var fileList = document.getElementById('file-list');
279
+ var startBtn = document.getElementById('start-btn');
280
+ var setupPanel = document.getElementById('setup-panel');
281
+ var chatPanel = document.getElementById('chat-panel');
282
+ var chatInput = document.getElementById('chat-input');
283
+ var chatHistory = document.getElementById('chat-history');
284
+ var sessionCostEl = document.getElementById('session-cost');
285
+
286
+ var selectedFiles = [];
287
+ var workspacePath = '';
288
+ var scratchPath = '';
289
+ var sessionCost = 0;
290
+
291
+ function updateStartBtn() {
292
+ startBtn.disabled = !(selectedFiles.length > 0);
293
+ }
294
+
295
+ function showFiles() {
296
+ fileList.innerHTML = selectedFiles.map(function(f) { return '<div>' + escapeHtml(f.name) + '</div>'; }).join('');
297
+ updateStartBtn();
298
+ }
299
+
300
+ fileInput.addEventListener('change', function() {
301
+ selectedFiles = Array.from(fileInput.files);
302
+ showFiles();
303
+ });
304
+
305
+ dropZone.addEventListener('click', function() { fileInput.click(); });
306
+ dropZone.addEventListener('dragover', function(e) { e.preventDefault(); dropZone.classList.add('drag-over'); });
307
+ dropZone.addEventListener('dragleave', function() { dropZone.classList.remove('drag-over'); });
308
+ dropZone.addEventListener('drop', function(e) {
309
+ e.preventDefault();
310
+ dropZone.classList.remove('drag-over');
311
+ selectedFiles = Array.from(e.dataTransfer.files);
312
+ showFiles();
313
+ });
314
+
315
+ startBtn.addEventListener('click', function() {
316
+ startBtn.disabled = true;
317
+ startBtn.textContent = 'Uploading...';
318
+
319
+ var formData = new FormData();
320
+ selectedFiles.forEach(function(f) { formData.append('files', f); });
321
+
322
+ fetch(API_BASE + '/gradio_api/call/upload', {
323
+ method: 'POST',
324
+ headers: { 'Content-Type': 'application/json' },
325
+ body: JSON.stringify({ data: [tokenInput.value] })
326
+ })
327
+ .then(function(r) { return r.json(); })
328
+ .then(function(result) {
329
+ return fetch(API_BASE + '/gradio_api/call/upload/' + result.event_id);
330
+ })
331
+ .then(function(r) { return r.text(); })
332
+ .then(function() {
333
+ setupPanel.style.display = 'none';
334
+ chatPanel.style.display = 'block';
335
+ chatInput.focus();
336
+ })
337
+ .catch(function() {
338
+ startBtn.disabled = false;
339
+ startBtn.textContent = 'Start exploring';
340
+ });
341
+ });
342
+
343
+ chatInput.addEventListener('keydown', function(e) {
344
+ if (e.key !== 'Enter' || !chatInput.value.trim() || chatInput.disabled) return;
345
+ e.preventDefault();
346
+ var question = chatInput.value.trim();
347
+ chatInput.value = '';
348
+ chatInput.disabled = true;
349
+ chatInput.placeholder = '';
350
+
351
+ var turn = document.createElement('div');
352
+ turn.className = 'chat-turn';
353
+ turn.innerHTML = '<div class="chat-question">' + escapeHtml(question) + '</div>';
354
+ chatHistory.appendChild(turn);
355
+
356
+ var answerEl = document.createElement('div');
357
+ answerEl.className = 'chat-answer';
358
+ turn.appendChild(answerEl);
359
+ var accumulated = '';
360
+ var toolCount = 0;
361
+
362
+ fetch(API_BASE + '/gradio_api/call/ask', {
363
+ method: 'POST',
364
+ headers: { 'Content-Type': 'application/json' },
365
+ body: JSON.stringify({ data: [question, workspacePath, scratchPath, sessionCost, tokenInput.value] })
366
+ })
367
+ .then(function(r) { return r.json(); })
368
+ .then(function(result) {
369
+ var eventSource = new EventSource(API_BASE + '/gradio_api/call/ask/' + result.event_id);
370
+
371
+ function handleEvent(e) {
372
+ var raw = JSON.parse(e.data);
373
+ var eventData = JSON.parse(Array.isArray(raw) ? raw[0] : raw);
374
+
375
+ if (eventData.type === 'delta') {
376
+ accumulated += eventData.content;
377
+ answerEl.innerHTML = markdownToHtml(accumulated);
378
+ answerEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
379
+ } else if (eventData.type === 'tool_call') {
380
+ toolCount = eventData.count;
381
+ answerEl.innerHTML = '<em class="chat-tool-status">Exploring documents (' + toolCount + ' tool calls)...</em>';
382
+ accumulated = '';
383
+ } else if (eventData.type === 'error') {
384
+ eventSource.close();
385
+ answerEl.innerHTML = '<span class="chat-error">' + escapeHtml(eventData.error) + '</span>';
386
+ chatInput.disabled = false;
387
+ chatInput.placeholder = 'Ask a question about your documents...';
388
+ } else if (eventData.type === 'done') {
389
+ eventSource.close();
390
+ var finalHtml = '<div class="chat-answer">' + markdownToHtml(eventData.answer || accumulated) + '</div>';
391
+ finalHtml += renderSources(eventData.sources);
392
+ if (eventData.stats) {
393
+ finalHtml += '<div class="chat-stats">' + eventData.stats + '</div>';
394
+ }
395
+ if (eventData.trace_html) {
396
+ finalHtml += '<button class="chat-trace-toggle" onclick="this.nextElementSibling.classList.toggle(\'open\')">trace</button>';
397
+ finalHtml += '<div class="chat-trace">' + eventData.trace_html + '</div>';
398
+ }
399
+ turn.innerHTML = '<div class="chat-question">' + escapeHtml(question) + '</div>' + finalHtml;
400
+
401
+ if (eventData.workspace_path) workspacePath = eventData.workspace_path;
402
+ if (eventData.scratch_path) scratchPath = eventData.scratch_path;
403
+ if (eventData.session_cost != null) {
404
+ sessionCost = eventData.session_cost;
405
+ sessionCostEl.textContent = 'Session cost: $' + sessionCost.toFixed(4);
406
+ }
407
+
408
+ chatInput.disabled = false;
409
+ chatInput.placeholder = 'Ask a question about your documents...';
410
+ chatInput.focus({ preventScroll: true });
411
+ }
412
+ }
413
+
414
+ eventSource.addEventListener('generating', handleEvent);
415
+ eventSource.addEventListener('complete', handleEvent);
416
+
417
+ eventSource.onerror = function() {
418
+ eventSource.close();
419
+ if (!accumulated) {
420
+ answerEl.innerHTML = '<span class="chat-error">Connection error.</span>';
421
+ }
422
+ chatInput.disabled = false;
423
+ chatInput.placeholder = 'Ask a question about your documents...';
424
+ };
425
+ })
426
+ .catch(function() {
427
+ turn.innerHTML += '<div class="chat-error">Connection error.</div>';
428
+ chatInput.disabled = false;
429
+ chatInput.placeholder = 'Ask a question about your documents...';
430
+ });
431
+ });
432
+ })();
433
+ </script>
434
+ """
435
 
436
 
437
  def build_app() -> gr.Blocks:
438
+ with gr.Blocks(title="Document Explorer") as demo:
439
+ gr.HTML(CUSTOM_HTML)
440
+
441
+ # Hidden state for file upload workspace
442
+ upload_workspace = gr.State("")
443
+
444
+ # Streaming ask endpoint
445
+ api_ask_input = [
446
+ gr.Textbox(visible=False), # question
447
+ gr.Textbox(visible=False), # workspace_path
448
+ gr.Textbox(visible=False), # scratch_path
449
+ gr.Number(visible=False), # session_cost
450
+ gr.Textbox(visible=False), # token
451
+ ]
452
+ api_ask_output = gr.Textbox(visible=False)
453
+
454
+ def api_ask_stream(question, workspace_path, scratch_path, session_cost, token):
455
+ for event_json in stream_question(
456
+ question, workspace_path, scratch_path, session_cost, token
457
+ ):
458
+ yield event_json
459
+
460
+ api_ask_btn = gr.Button(visible=False)
461
+ api_ask_btn.click(
462
+ api_ask_stream,
463
+ inputs=api_ask_input,
464
+ outputs=api_ask_output,
465
+ api_name="ask",
466
+ )
467
 
468
+ # Upload endpoint (validates token, returns confirmation)
469
+ upload_input = gr.Textbox(visible=False)
470
+ upload_output = gr.Textbox(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
+ def api_upload(token):
473
+ if ACCESS_TOKEN and token != ACCESS_TOKEN:
474
+ return json.dumps({"error": "Invalid access token."})
475
+ return json.dumps({"ok": True})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
+ upload_btn = gr.Button(visible=False)
478
+ upload_btn.click(api_upload, inputs=upload_input, outputs=upload_output, api_name="upload")
479
+
480
+ # Document viewer endpoint
481
+ doc_input = gr.Textbox(visible=False)
482
+ doc_output = gr.Textbox(visible=False)
483
+
484
+ def api_get_doc(filename):
485
+ return json.dumps({"error": "not available"})
486
+
487
+ doc_btn = gr.Button(visible=False)
488
+ doc_btn.click(api_get_doc, inputs=doc_input, outputs=doc_output, api_name="doc")
489
+
490
+ # Trace list endpoint
491
+ traces_input = gr.Textbox(visible=False)
492
+ traces_output = gr.Textbox(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
 
494
+ def api_list_traces(query):
495
+ if not hf_api or not HF_TRACES_REPO:
496
+ return json.dumps({"error": "traces not configured"})
497
+ try:
498
+ files = hf_api.list_repo_files(
499
+ repo_id=HF_TRACES_REPO, repo_type="dataset"
500
  )
501
+ traces = sorted(
502
+ [f for f in files if f.endswith(".json")], reverse=True
 
 
503
  )
504
+ if query:
505
+ traces = [f for f in traces if query.lower() in f.lower()]
506
+ return json.dumps({"traces": traces[:100]})
507
+ except Exception as exc:
508
+ return json.dumps({"error": str(exc)})
509
+
510
+ traces_btn = gr.Button(visible=False)
511
+ traces_btn.click(api_list_traces, inputs=traces_input, outputs=traces_output, api_name="traces")
512
+
513
+ # Trace replay endpoint
514
+ replay_input = gr.Textbox(visible=False)
515
+ replay_output = gr.Textbox(visible=False)
516
+
517
+ def api_get_trace(filename):
518
+ if not hf_api or not HF_TRACES_REPO or not filename:
519
+ return json.dumps({"error": "not found"})
520
+ safe_name = Path(filename).name
521
+ try:
522
+ path = hf_api.hf_hub_download(
523
+ HF_TRACES_REPO, safe_name, repo_type="dataset"
524
  )
525
+ data = json.loads(Path(path).read_text())
526
+ trace = data.get("trace", {})
527
+ raw_answer = trace.get("answer", "")
528
+ clean_answer, sources = process_citations(raw_answer, None)
529
+ trace_html = render_trace(data, max_chars=2000)
530
+ return json.dumps({
531
+ "question": data.get("question", ""),
532
+ "answer": clean_answer,
533
+ "sources": sources,
534
+ "stats": format_stats_from_trace(trace),
535
+ "source_tag": data.get("source", ""),
536
+ "trace_html": trace_html,
537
+ "filename": safe_name,
538
+ })
539
+ except Exception as exc:
540
+ return json.dumps({"error": str(exc)})
541
+
542
+ replay_btn = gr.Button(visible=False)
543
+ replay_btn.click(api_get_trace, inputs=replay_input, outputs=replay_output, api_name="replay")
544
 
545
  return demo
546
 
 
550
  print("WARNING: LH_ACCESS_TOKEN not set — app is unprotected")
551
 
552
  app = build_app()
553
+ app.launch(server_name="0.0.0.0", server_port=7860)
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
  click>=8.1
2
  litellm @ git+https://github.com/BerriAI/litellm.git
3
- a-simple-llm-harness @ git+https://github.com/chuckfinca/a-simple-llm-harness.git
4
  e2b-code-interpreter>=2.5
5
  huggingface-hub
6
  python-dotenv
 
1
  click>=8.1
2
  litellm @ git+https://github.com/BerriAI/litellm.git
3
+ a-simple-llm-harness @ git+https://github.com/chuckfinca/a-simple-llm-harness.git@acfd4f2
4
  e2b-code-interpreter>=2.5
5
  huggingface-hub
6
  python-dotenv
static/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ .gitkeep