akhaliq HF Staff Claude commited on
Commit
62af584
Β·
1 Parent(s): 2183262

Switch to gradio.Server + custom HTML terminal UI

Browse files

- app.py: gradio.Server app exposing /chat (SSE via @app .api, generator
yielding per-line deltas) and / serving the static HTML page
- index.html: hand-rolled terminal-style UI (mac dots, JetBrains Mono, ANSI
rendering) talking to /chat through the Gradio JS client; preserves
reasoning_details across turns so Laguna continues reasoning next call
- requirements.txt: pin gradio 6.20.0, add openai

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +130 -235
  2. index.html +300 -0
  3. requirements.txt +2 -0
app.py CHANGED
@@ -1,13 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import time
 
3
 
4
  from openai import OpenAI
5
 
6
- import gradio as gr
 
7
 
8
  # ---------------------------------------------------------------------------
9
- # Client setup. The OpenRouter API key is read from the environment; on HF
10
- # Spaces it is exposed as a secret named OPENROUTER_API_KEY.
11
  # ---------------------------------------------------------------------------
12
  API_KEY = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("API_KEY") or ""
13
  MODEL = os.environ.get("MODEL", "poolside/laguna-s-2.1:free")
@@ -17,21 +32,9 @@ client = OpenAI(
17
  api_key=API_KEY or "missing-key",
18
  )
19
 
20
-
21
- def chat_with_reasoning(messages, reasoning_enabled=True):
22
- """One reasoning-aware call. reasoning_details is kept on the assistant
23
- message so the model can continue reasoning on the next turn."""
24
- response = client.chat.completions.create(
25
- model=MODEL,
26
- messages=messages,
27
- extra_body={"reasoning": {"enabled": reasoning_enabled}},
28
- )
29
- return response.choices[0].message
30
-
31
-
32
  # ---------------------------------------------------------------------------
33
- # ANSI styling for the terminal look. The Chatbot renders content verbatim
34
- # (render_markdown=False), so we emit raw escape codes and style the container.
35
  # ---------------------------------------------------------------------------
36
  RESET = "\033[0m"
37
  BOLD = "\033[1m"
@@ -40,33 +43,33 @@ ITALIC = "\033[3m"
40
  GREEN = "\033[32m"
41
  CYAN = "\033[36m"
42
  YELLOW = "\033[33m"
 
43
  RED = "\033[31m"
44
  GREY = "\033[90m"
45
  BOLD_GREEN = BOLD + GREEN
 
46
 
47
 
48
  def _extract_reasoning(reasoning_details) -> str:
49
- """Pull readable text out of reasoning_details, tolerating shapes returned
50
- by OpenRouter (list of part dicts / nested summaries)."""
51
  if not reasoning_details:
52
  return ""
53
-
54
- chunks = []
55
 
56
  def push(val):
57
  if isinstance(val, str):
58
  chunks.append(val)
59
  elif isinstance(val, list):
60
- for item in val:
61
- push(item)
62
  elif isinstance(val, dict):
63
- for key in ("summary", "text", "content", "reasoning"):
64
- if key in val:
65
- push(val[key])
66
 
67
  if isinstance(reasoning_details, list):
68
- for part in reasoning_details:
69
- push(part)
70
  elif isinstance(reasoning_details, dict):
71
  push(reasoning_details)
72
  elif isinstance(reasoning_details, str):
@@ -79,229 +82,121 @@ def _one_line(text: str) -> str:
79
  return (text or "").replace("\n", " ").strip()
80
 
81
 
 
 
 
 
 
82
  # ---------------------------------------------------------------------------
83
- # Conversation state.
84
- #
85
- # `state` holds the OpenAI messages list, where each assistant turn keeps its
86
- # raw `reasoning_details`. Passing those back unmodified is what lets Laguna
87
- # continue reasoning from where it left off on the next turn.
88
- #
89
- # `display` is the messages-format list shown in the Chatbot (ANSI-styled).
90
  # ---------------------------------------------------------------------------
91
- def _user_line(msg: str) -> str:
92
- return f"{GREY}$ {RESET}{GREEN}user{RESET} {DIM}Β»{RESET} {_one_line(msg)}"
93
 
94
 
95
- def _start_assistant(reasoning_enabled: bool) -> str:
96
- hint = "thinking…" if reasoning_enabled else "responding…"
97
- return (
98
- f"{CYAN}laguna{RESET}{GREY}@{RESET}{YELLOW}openrouter{RESET}"
99
- f"{GREY}:{RESET}{DIM}~{RESET} {GREY}{hint}{RESET}\n"
100
- )
101
-
102
 
103
- def respond(user_message, state, reasoning_enabled):
104
- """Stream a single turn. Yields (display_history, new_state) tuples."""
105
- state = list(state or [])
 
106
 
107
- # Append the user turn to the OpenAI conversation.
108
- state.append({"role": "user", "content": user_message})
 
 
109
 
110
- display = []
111
- display.append({"role": "user", "content": _user_line(user_message)})
112
- assistant_text = _start_assistant(reasoning_enabled)
113
- display.append({"role": "assistant", "content": assistant_text})
114
- yield display, state
 
 
115
 
116
  try:
117
- message = chat_with_reasoning(state, reasoning_enabled)
118
-
119
- # Cache the assistant turn (with reasoning_details) so the next turn can
120
- # continue reasoning from here.
121
- assistant_turn = {"role": "assistant", "content": message.content or ""}
122
- rd = getattr(message, "reasoning_details", None)
123
- if rd:
124
- assistant_turn["reasoning_details"] = rd
125
-
126
- out = _start_assistant(reasoning_enabled)
127
-
128
- reasoning = _extract_reasoning(rd)
129
- if reasoning:
130
- out += f"{DIM}{ITALIC}β”Œβ”€ reasoning ───────────────────────{RESET}\n"
131
- display[-1]["content"] = out
132
- yield display, state
133
- for line in reasoning.splitlines() or [""]:
134
- prefix = f"{DIM}{ITALIC}β”‚ {RESET}" if line else f"{DIM}β”‚{RESET} "
135
- out += prefix + line + "\n"
136
- display[-1]["content"] = out
137
- yield display, state
138
- time.sleep(0.012)
139
- out += f"{DIM}{ITALIC}└───────────────────────────────────{RESET}\n"
140
-
141
- answer = (message.content or "").strip()
142
- out += f"{BOLD_GREEN}β”Œβ”€ laguna ──────────────────────────{RESET}\n"
143
- display[-1]["content"] = out
144
- yield display, state
145
- for line in answer.splitlines() or [""]:
146
- prefix = f"{BOLD_GREEN}β”‚ {RESET}" if line else f"{GREEN}β”‚{RESET} "
147
- out += prefix + line + "\n"
148
- display[-1]["content"] = out
149
- yield display, state
150
- time.sleep(0.012)
151
- out += f"{BOLD_GREEN}└───────────────────────────────────{RESET}\n"
152
- out += f"{GREY}── done ──{RESET}\n"
153
- display[-1]["content"] = out
154
  except Exception as e:
155
- display[-1]["content"] += f"\n{BOLD}{RED}error{RESET} {DIM}Β»{RESET} {e}\n"
156
- finally:
157
- # Persist the assistant turn into the OpenAI conversation state so
158
- # reasoning_details survive to the next call.
159
- state.append(assistant_turn)
160
- yield display, state
161
-
162
-
163
- def reset_state():
164
- return [], []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
 
167
  # ---------------------------------------------------------------------------
168
- # Terminal UI.
 
169
  # ---------------------------------------------------------------------------
170
- CSS = """
171
- #term-root { max-width: 940px; margin: 0 auto; }
172
- .term-header {
173
- background: linear-gradient(180deg, #1e1e2e 0%, #181825 100%);
174
- border: 1px solid #313244; border-radius: 10px 10px 0 0;
175
- padding: 10px 14px; display: flex; align-items: center; gap: 8px;
176
- font-family: 'JetBrains Mono','SF Mono','Fira Code','Menlo',monospace;
177
- }
178
- .term-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
179
- .dot-r { background: #f38ba8; } .dot-y { background: #f9e2af; } .dot-g { background: #a6e3a1; }
180
- .term-title { margin-left: 8px; color: #cdd6f4; font-size: 13px; }
181
- .term-title b { color: #89b4fa; }
182
- /* Chatbot becomes the terminal body */
183
- #term-body { background: #11111b !important; border: 1px solid #313244 !important;
184
- border-top: none !important; border-radius: 0 0 10px 10px !important; }
185
- #term-body .msg { padding: 8px 16px !important; }
186
- #term-body .message-row, #term-body .message-wrap,
187
- #term-body .message { padding: 0 !important; background: transparent !important;
188
- border: none !important; box-shadow: none !important; }
189
- #term-body .avatar-image, #term-body .avatar { display: none !important; }
190
- #term-body .md, #term-body .message-body p {
191
- font-family: 'JetBrains Mono','SF Mono','Fira Code','Menlo',monospace !important;
192
- font-size: 13px !important; line-height: 1.5 !important; color: #cdd6f4 !important;
193
- white-space: pre-wrap !important; margin: 0 !important;
194
- }
195
- #term-body .user-message .md, #term-body .user .md { color: #a6e3a1 !important; }
196
- #term-body .bot-message .md, #term-body .bot .md { color: #cdd6f4 !important; }
197
- #term-body .empty { color: #585b70 !important; font-family: monospace !important; }
198
- #term-body ::-webkit-scrollbar { width: 8px; }
199
- #term-body ::-webkit-scrollbar-thumb { background: #313244; border-radius: 4px; }
200
- #prompt-row { max-width: 940px; margin: 12px auto 0 auto; }
201
- #prompt-row .wrap { gap: 0 !important; }
202
- #term-foot { max-width: 940px; margin: 8px auto 28px auto; color: #7f849c;
203
- font-family: monospace; font-size: 11px; }
204
- #term-foot b { color: #89b4fa; }
205
- footer { display: none !important; }
206
- """
207
 
208
- JS = """
209
- () => {
210
- const body = document.querySelector('#term-body .scroll-container') ||
211
- document.querySelector('#term-body');
212
- if (body) {
213
- const scroll = () => { body.scrollTop = body.scrollHeight; };
214
- new MutationObserver(scroll).observe(body, { childList: true, subtree: true });
215
- scroll();
216
- }
217
- }
218
- """
219
 
220
- HEADER_HTML = """
221
- <div class="term-header">
222
- <span class="term-dot dot-r"></span>
223
- <span class="term-dot dot-y"></span>
224
- <span class="term-dot dot-g"></span>
225
- <span class="term-title">laguna-s-2.1 β€” <b>reasoning terminal</b></span>
226
- </div>
227
- """
228
-
229
- PLACEHOLDER = (
230
- "laguna-s-2.1 ready.\n"
231
- "Type a question; the model reasons before it answers.\n"
232
- )
233
-
234
- with gr.Blocks(
235
- css=CSS,
236
- theme=gr.themes.Default(
237
- primary_hue="blue",
238
- neutral_hue="slate",
239
- font=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"],
240
- ),
241
- title="Laguna S 2.1 β€” Reasoning Terminal",
242
- ) as demo:
243
- gr.HTML(HEADER_HTML)
244
-
245
- convo_state = gr.State(value=[])
246
-
247
- with gr.Column(elem_id="term-root"):
248
- chatbot = gr.Chatbot(
249
- label=None,
250
- type="messages",
251
- height=580,
252
- elem_id="term-body",
253
- show_label=False,
254
- avatar_images=(None, None),
255
- render_markdown=False,
256
- placeholder=PLACEHOLDER,
257
- )
258
- with gr.Row(elem_id="prompt-row"):
259
- txt = gr.Textbox(
260
- placeholder="ask laguna anything… (e.g. How many r's in 'strawberry'?)",
261
- show_label=False,
262
- scale=8,
263
- container=False,
264
- autofocus=True,
265
- lines=1,
266
- max_lines=3,
267
- )
268
- reasoning_toggle = gr.Checkbox(
269
- label="reasoning", value=True, scale=1, container=False
270
- )
271
- send_btn = gr.Button("⏎ send", variant="primary", scale=1)
272
- clear_btn = gr.Button("clear", scale=1)
273
-
274
- gr.HTML(
275
- "<div id='term-foot'>"
276
- "model: <b>poolside/laguna-s-2.1:free</b> Β· via openrouter Β· "
277
- "reasoning_details preserved across turns"
278
- "</div>"
279
- )
280
-
281
- def _on_submit(user_message, state, reasoning_enabled):
282
- # Drop empty submits.
283
- if not (user_message or "").strip():
284
- return None, state, gr.update()
285
- return user_message, state, reasoning_enabled
286
-
287
- send_event = txt.submit(
288
- fn=respond,
289
- inputs=[txt, convo_state, reasoning_toggle],
290
- outputs=[chatbot, convo_state],
291
- ).then(fn=lambda: gr.update(value=""), outputs=txt)
292
-
293
- send_btn.click(
294
- fn=respond,
295
- inputs=[txt, convo_state, reasoning_toggle],
296
- outputs=[chatbot, convo_state],
297
- ).then(fn=lambda: gr.update(value=""), outputs=txt)
298
-
299
- clear_btn.click(
300
- fn=reset_state,
301
- outputs=[chatbot, convo_state],
302
- )
303
 
304
- demo.load(fn=None, js=JS)
305
 
306
  if __name__ == "__main__":
307
- demo.launch()
 
1
+ """
2
+ Laguna S 2.1 β€” reasoning terminal.
3
+
4
+ A gradio.Server app: the FastAPI layer exposes one streaming endpoint at
5
+ /chat (SSE through Gradio's queue) and serves a hand-rolled HTML/CSS/JS
6
+ terminal from "/". The frontend uses Gradio's JS client to call /chat, so
7
+ it picks up queuing, SSE streaming, and concurrency control for free.
8
+
9
+ Reasoning is preserved across turns: the frontend re-sends prior assistant
10
+ turns with their `reasoning_details`, so Laguna S 2.1 continues reasoning
11
+ from where it left off on the previous call.
12
+ """
13
+
14
+ import json
15
  import os
16
  import time
17
+ from typing import Iterator
18
 
19
  from openai import OpenAI
20
 
21
+ from gradio import Server
22
+ from fastapi.responses import HTMLResponse
23
 
24
  # ---------------------------------------------------------------------------
25
+ # Backend: OpenAI-compatible client pointed at OpenRouter.
 
26
  # ---------------------------------------------------------------------------
27
  API_KEY = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("API_KEY") or ""
28
  MODEL = os.environ.get("MODEL", "poolside/laguna-s-2.1:free")
 
32
  api_key=API_KEY or "missing-key",
33
  )
34
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # ---------------------------------------------------------------------------
36
+ # ANSI palette for the terminal look. The frontend keeps this table so it
37
+ # can render lines verbatim (no markdown parsing).
38
  # ---------------------------------------------------------------------------
39
  RESET = "\033[0m"
40
  BOLD = "\033[1m"
 
43
  GREEN = "\033[32m"
44
  CYAN = "\033[36m"
45
  YELLOW = "\033[33m"
46
+ MAGENTA = "\033[35m"
47
  RED = "\033[31m"
48
  GREY = "\033[90m"
49
  BOLD_GREEN = BOLD + GREEN
50
+ BOLD_CYAN = BOLD + CYAN
51
 
52
 
53
  def _extract_reasoning(reasoning_details) -> str:
54
+ """Read text out of OpenRouter reasoning_details (list/dict/str, nested)."""
 
55
  if not reasoning_details:
56
  return ""
57
+ chunks: list[str] = []
 
58
 
59
  def push(val):
60
  if isinstance(val, str):
61
  chunks.append(val)
62
  elif isinstance(val, list):
63
+ for v in val:
64
+ push(v)
65
  elif isinstance(val, dict):
66
+ for k in ("summary", "text", "content", "reasoning"):
67
+ if k in val:
68
+ push(val[k])
69
 
70
  if isinstance(reasoning_details, list):
71
+ for p in reasoning_details:
72
+ push(p)
73
  elif isinstance(reasoning_details, dict):
74
  push(reasoning_details)
75
  elif isinstance(reasoning_details, str):
 
82
  return (text or "").replace("\n", " ").strip()
83
 
84
 
85
+ def _emit(payload: dict) -> dict:
86
+ """Strip non-JSON-safe bits before yielding (we only stream ASCII)."""
87
+ return {"event": payload.get("event", "delta"), "text": payload.get("text", "")}
88
+
89
+
90
  # ---------------------------------------------------------------------------
91
+ # /chat endpoint. With gradio.Server, a generator-typed function streams via
92
+ # SSE. We yield small text events (think: print-style lines the terminal
93
+ # renders as they arrive) and finish with a `done` event carrying the new
94
+ # assistant message β€” the frontend appends that to its in-memory history
95
+ # along with the original `reasoning_details`.
 
 
96
  # ---------------------------------------------------------------------------
97
+ app = Server()
 
98
 
99
 
100
+ @app.api()
101
+ def chat(payload: dict) -> Iterator[dict]:
102
+ messages = payload.get("messages") or []
103
+ reasoning = bool(payload.get("reasoning", True))
 
 
 
104
 
105
+ last_user = next(
106
+ (m for m in reversed(messages) if m.get("role") == "user"), None
107
+ )
108
+ user_text = (last_user or {}).get("content", "")
109
 
110
+ yield _emit({
111
+ "event": "delta",
112
+ "text": f"{GREY}$ {RESET}{GREEN}user{RESET} {DIM}Β»{RESET} {_one_line(user_text)}\n",
113
+ })
114
 
115
+ yield _emit({
116
+ "event": "delta",
117
+ "text": (
118
+ f"{CYAN}laguna{RESET}{GREY}@{RESET}{YELLOW}openrouter{RESET}"
119
+ f"{GREY}:{RESET}{DIM}~{RESET} {GREY}{'thinking…' if reasoning else 'responding…'}{RESET}\n"
120
+ ),
121
+ })
122
 
123
  try:
124
+ response = client.chat.completions.create(
125
+ model=MODEL,
126
+ messages=messages,
127
+ extra_body={"reasoning": {"enabled": reasoning}},
128
+ )
129
+ message = response.choices[0].message
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  except Exception as e:
131
+ yield _emit({
132
+ "event": "delta",
133
+ "text": f"\n{BOLD}{RED}error{RESET} {DIM}Β»{RESET} {e}\n",
134
+ })
135
+ yield {"event": "done", "text": "", "assistant": None}
136
+ return
137
+
138
+ reasoning_text = _extract_reasoning(getattr(message, "reasoning_details", None))
139
+
140
+ if reasoning_text:
141
+ yield _emit({
142
+ "event": "delta",
143
+ "text": f"{DIM}{ITALIC}β”Œβ”€ reasoning ───────────────────────{RESET}\n",
144
+ })
145
+ for line in reasoning_text.splitlines() or [""]:
146
+ prefix = f"{DIM}{ITALIC}β”‚ {RESET}" if line else f"{DIM}β”‚{RESET} "
147
+ yield _emit({"event": "delta", "text": prefix + line + "\n"})
148
+ time.sleep(0.012)
149
+ yield _emit({
150
+ "event": "delta",
151
+ "text": f"{DIM}{ITALIC}└───────────────────────────────────{RESET}\n",
152
+ })
153
+
154
+ answer = (message.content or "").strip()
155
+ yield _emit({
156
+ "event": "delta",
157
+ "text": f"{BOLD_GREEN}β”Œβ”€ laguna ───────────��──────────────{RESET}\n",
158
+ })
159
+ for line in answer.splitlines() or [""]:
160
+ prefix = f"{BOLD_GREEN}β”‚ {RESET}" if line else f"{GREEN}β”‚{RESET} "
161
+ yield _emit({"event": "delta", "text": prefix + line + "\n"})
162
+ time.sleep(0.012)
163
+ yield _emit({
164
+ "event": "delta",
165
+ "text": f"{BOLD_GREEN}└───────────────────────────────────{RESET}\n",
166
+ })
167
+ yield _emit({"event": "delta", "text": f"{GREY}── done ──{RESET}\n"})
168
+
169
+ # The new assistant message β€” keep reasoning_details so the JS client
170
+ # can re-send it on the next turn, letting Laguna continue its reasoning.
171
+ rd = getattr(message, "reasoning_details", None)
172
+ rd_json = json.loads(json.dumps(rd, default=str)) if rd else None
173
+
174
+ yield {
175
+ "event": "done",
176
+ "text": "",
177
+ "assistant": {
178
+ "role": "assistant",
179
+ "content": message.content or "",
180
+ "reasoning_details": rd_json,
181
+ },
182
+ }
183
 
184
 
185
  # ---------------------------------------------------------------------------
186
+ # "/" serves the hand-rolled HTML terminal. It uses Gradio's JS client
187
+ # from a CDN, connects to the same origin, and calls /chat with SSE.
188
  # ---------------------------------------------------------------------------
189
+ @app.get("/", response_class=HTMLResponse)
190
+ async def homepage():
191
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
192
+ with open(html_path, "r", encoding="utf-8") as f:
193
+ return f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
+ @app.get("/healthz")
197
+ async def healthz():
198
+ return {"ok": True, "model": MODEL}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
 
200
 
201
  if __name__ == "__main__":
202
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>laguna-s-2.1 β€” reasoning terminal</title>
7
+ <link rel="icon" href="https://huggingface.co/favicon.ico" />
8
+ <style>
9
+ :root {
10
+ --bg-0: #11111b;
11
+ --bg-1: #1e1e2e;
12
+ --bg-2: #181825;
13
+ --border: #313244;
14
+ --fg: #cdd6f4;
15
+ --muted: #7f849c;
16
+ --dim: #585b70;
17
+ --accent: #89b4fa;
18
+ --green: #a6e3a1;
19
+ --yellow: #f9e2af;
20
+ --rose: #f38ba8;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ html, body { margin: 0; padding: 0; height: 100%; }
24
+ body {
25
+ background: radial-gradient(1200px 600px at 50% -100px, #1a1b27 0%, #0b0b14 70%);
26
+ color: var(--fg);
27
+ font-family: 'JetBrains Mono','Fira Code','SF Mono','Menlo',monospace;
28
+ display: flex; flex-direction: column; align-items: center;
29
+ padding: 24px 16px 40px;
30
+ min-height: 100%;
31
+ }
32
+ .frame { width: 100%; max-width: 940px; }
33
+ .header {
34
+ display: flex; align-items: center; gap: 10px;
35
+ background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%);
36
+ border: 1px solid var(--border);
37
+ border-radius: 10px 10px 0 0;
38
+ padding: 10px 14px;
39
+ }
40
+ .dots { display: inline-flex; gap: 7px; }
41
+ .dot { width: 12px; height: 12px; border-radius: 50%; }
42
+ .dot.r { background: var(--rose); }
43
+ .dot.y { background: var(--yellow); }
44
+ .dot.g { background: var(--green); }
45
+ .brand { display: flex; align-items: center; gap: 8px; margin-left: 10px; color: var(--fg); font-size: 13px; }
46
+ .brand .hf { width: 18px; height: 18px; }
47
+ .brand .name { color: var(--accent); font-weight: 700; }
48
+ .brand .sep { color: var(--dim); }
49
+ .brand .meta { color: var(--muted); font-size: 12px; margin-left: auto; }
50
+ .badge {
51
+ display: inline-flex; align-items: center; gap: 6px;
52
+ background: rgba(137,180,250,0.12); color: var(--accent);
53
+ padding: 3px 8px; border-radius: 999px; font-size: 11px;
54
+ border: 1px solid rgba(137,180,250,0.25);
55
+ }
56
+ .term {
57
+ background: var(--bg-0);
58
+ border: 1px solid var(--border);
59
+ border-top: none;
60
+ border-radius: 0 0 10px 10px;
61
+ padding: 14px 16px 8px;
62
+ height: 580px;
63
+ overflow: auto;
64
+ font-size: 13px;
65
+ line-height: 1.55;
66
+ white-space: pre-wrap;
67
+ word-break: break-word;
68
+ scrollbar-width: thin;
69
+ scrollbar-color: var(--border) transparent;
70
+ }
71
+ .term::-webkit-scrollbar { width: 8px; }
72
+ .term::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
73
+ .promptbar {
74
+ display: flex; gap: 0; align-items: stretch;
75
+ margin-top: 14px;
76
+ }
77
+ .promptbar .seal {
78
+ background: var(--bg-1); border: 1px solid var(--border); border-right: none;
79
+ border-radius: 10px 0 0 10px; padding: 0 14px;
80
+ display: flex; align-items: center; color: var(--green);
81
+ font-weight: 700;
82
+ }
83
+ .promptbar input[type="text"] {
84
+ flex: 1; min-width: 0;
85
+ background: var(--bg-0); color: var(--fg);
86
+ border: 1px solid var(--border);
87
+ padding: 12px 14px; font: inherit; outline: none;
88
+ }
89
+ .promptbar input[type="text"]:focus { border-color: var(--accent); }
90
+ .promptbar .toggle {
91
+ display: flex; align-items: center; gap: 6px;
92
+ background: var(--bg-0); border: 1px solid var(--border); border-left: none;
93
+ padding: 0 12px; font-size: 12px; color: var(--muted);
94
+ cursor: pointer; user-select: none;
95
+ }
96
+ .promptbar .toggle input { accent-color: var(--accent); }
97
+ .promptbar button.send {
98
+ background: var(--accent); color: #11111b; border: 1px solid var(--accent);
99
+ border-left: none; border-radius: 0 10px 10px 0; padding: 0 18px;
100
+ font: inherit; font-weight: 700; cursor: pointer;
101
+ }
102
+ .promptbar button.send:disabled { opacity: 0.55; cursor: not-allowed; }
103
+ .promptbar button.clear {
104
+ background: transparent; color: var(--muted); border: 1px solid var(--border);
105
+ border-left: none; padding: 0 14px; font: inherit; cursor: pointer;
106
+ }
107
+ .promptbar button.clear:hover { color: var(--rose); }
108
+ .foot { color: var(--muted); font-size: 11px; margin-top: 10px; }
109
+ .foot a { color: var(--accent); text-decoration: none; }
110
+ .caret::after {
111
+ content: "▍"; color: var(--accent); animation: blink 1s steps(2) infinite;
112
+ margin-left: 2px;
113
+ }
114
+ @keyframes blink { 50% { opacity: 0; } }
115
+ </style>
116
+ </head>
117
+ <body>
118
+ <div class="frame">
119
+ <div class="header">
120
+ <span class="dots">
121
+ <span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
122
+ </span>
123
+ <span class="brand">
124
+ <svg class="hf" viewBox="0 0 95 88" xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true">
125
+ <path d="M47.41 0c-1.05 0-2.09.27-3.03.81L36.04 5.97a2.4 2.4 0 0 0-1.2 2.08v9.71c0 .86.45 1.65 1.2 2.08l8.34 4.79c.75.43 1.65.43 2.4 0l8.34-4.79a2.4 2.4 0 0 0 1.2-2.08V8.05a2.4 2.4 0 0 0-1.2-2.08L50.44.81A4.78 4.78 0 0 0 47.41 0Z"/>
126
+ <path d="M87.06 28.18 78.71 23.4a2.4 2.4 0 0 0-2.4 0l-8.34 4.79a2.4 2.4 0 0 0-1.2 2.08v9.61c0 .86.45 1.65 1.2 2.08l8.34 4.79c.75.43 1.65.43 2.4 0l8.35-4.79a2.4 2.4 0 0 0 1.2-2.08v-9.61a2.4 2.4 0 0 0-1.2-2.08Z"/>
127
+ <path d="M7.76 28.18 16.11 23.4c.75-.43 1.65-.43 2.4 0l8.34 4.79a2.4 2.4 0 0 1 1.2 2.08v9.61c0 .86-.45 1.65-1.2 2.08l-8.34 4.79c-.75.43-1.65.43-2.4 0l-8.35-4.79a2.4 2.4 0 0 1-1.2-2.08v-9.61c0-.86.45-1.65 1.2-2.08Z"/>
128
+ <path d="M43.51 30.32c.7-.4 1.58-.4 2.27 0l21.85 12.65c.7.4 1.13 1.15 1.13 1.95v25.3c0 .8-.43 1.55-1.13 1.95l-21.85 12.65c-.7.4-1.58.4-2.27 0L21.66 72.17a2.27 2.27 0 0 1-1.13-1.95v-25.3c0-.8.43-1.55 1.13-1.95L43.51 30.32Z"/>
129
+ </svg>
130
+ <span class="name">huggingface</span><span class="sep">β€Ί</span><span>laguna-s-2.1</span>
131
+ <span class="meta"><span class="badge">● online Β· reasoning preserved across turns</span></span>
132
+ </span>
133
+ </div>
134
+
135
+ <div id="term" class="term" aria-live="polite"></div>
136
+
137
+ <div class="promptbar">
138
+ <div class="seal">$&nbsp;laguna&nbsp;&gt;</div>
139
+ <input id="input" type="text" autocomplete="off"
140
+ placeholder="ask anything… e.g. How many r's are in 'strawberry'?" />
141
+ <label class="toggle"><input id="reasoning" type="checkbox" checked /> reasoning</label>
142
+ <button id="clear" class="clear" type="button">clear</button>
143
+ <button id="send" class="send" type="button">send ↡</button>
144
+ </div>
145
+
146
+ <div class="foot">
147
+ model: <span style="color:var(--accent)">poolside/laguna-s-2.1:free</span>
148
+ Β· via openrouter Β· lossless reasoning memory via <code>reasoning_details</code>
149
+ Β· hf spaces + gradio <code>Server</code>
150
+ </div>
151
+ </div>
152
+
153
+ <script type="module">
154
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
155
+
156
+ const term = document.getElementById("term");
157
+ const input = document.getElementById("input");
158
+ const sendBtn = document.getElementById("send");
159
+ const clearBtn = document.getElementById("clear");
160
+ const reasoning = document.getElementById("reasoning");
161
+
162
+ // messages the model sees β€” includes reasoning_details on prior assistant turns
163
+ let messages = [];
164
+
165
+ const ANSI = {
166
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", italic: "\x1b[3m",
167
+ green: "\x1b[32m", cyan: "\x1b[36m", yellow: "\x1b[33m",
168
+ grey: "\x1b[90m", red: "\x1b[31m",
169
+ };
170
+
171
+ const banner = [
172
+ `${ANSI.cyan}β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€${ANSI.reset}`,
173
+ `${ANSI.cyan}β”‚${ANSI.reset} ${ANSI.bold}laguna-s-2.1 reasoning terminal${ANSI.reset} ${ANSI.dim}Β· hf spaces${ANSI.reset}`,
174
+ `${ANSI.cyan}β”‚${ANSI.reset} ${ANSI.dim}model:${ANSI.reset} ${ANSI.green}poolside/laguna-s-2.1:free${ANSI.reset} ${ANSI.dim}Β·${ANSI.reset} ${ANSI.green}reasoning preserved across turns${ANSI.reset}`,
175
+ `${ANSI.cyan}└──────────────────────────────────────────────────────────────${ANSI.reset}`,
176
+ "",
177
+ `${ANSI.dim}type a question and press ↡. toggle ${ANSI.reset}${ANSI.italic}reasoning${ANSI.reset}${ANSI.dim} on the right to control thinking.${ANSI.reset}`,
178
+ "",
179
+ ].join("\n");
180
+
181
+ function esc(s) { return String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])); }
182
+ // Render ANSI escape codes as inline-styled spans.
183
+ function render(raw) {
184
+ // Tokenize over SGR codes.
185
+ const out = [];
186
+ const re = /\x1b\[(\d+)m/g;
187
+ let last = 0, m, codes = 0;
188
+ const state = (add) => {
189
+ codes |= add;
190
+ let bold = !!(codes & 1),
191
+ dim = !!(codes & 2), italic = !!(codes & 4),
192
+ green = !!(codes & 32), cyan = !!(codes & 64),
193
+ yellow = !!(codes & 128), grey = !!(codes & 256), red = !!(codes & 512);
194
+ const styles = [];
195
+ if (bold) styles.push("font-weight:700");
196
+ if (dim) styles.push("opacity:0.65");
197
+ if (italic) styles.push("font-style:italic");
198
+ if (green) styles.push("color:#a6e3a1");
199
+ else if (cyan) styles.push("color:#89dceb");
200
+ else if (yellow) styles.push("color:#f9e2af");
201
+ else if (grey) styles.push("color:#7f849c");
202
+ else if (red) styles.push("color:#f38ba8");
203
+ if (!styles.length) styles.push("color:#cdd6f4");
204
+ return styles.join(";");
205
+ };
206
+ while ((m = re.exec(raw)) !== null) {
207
+ const txt = raw.slice(last, m.index);
208
+ if (txt) out.push(`<span style="${state(0)}">${esc(txt).replace(/\n/g, "<br>")}</span>`);
209
+ const code = +m[1];
210
+ if (code === 0) codes = 0; else codes |= (1 << (code - 1));
211
+ last = m.index + m[0].length;
212
+ }
213
+ if (last < raw.length) {
214
+ out.push(`<span style="${state(0)}">${esc(raw.slice(last)).replace(/\n/g, "<br>")}</span>`);
215
+ }
216
+ return out.join("");
217
+ }
218
+
219
+ term.innerHTML = render(banner);
220
+ term.scrollTop = term.scrollHeight;
221
+
222
+ function appendText(text) {
223
+ const span = document.createElement("span");
224
+ // strip the existing trailing caret before re-rendering
225
+ const caret = term.querySelector(".caret");
226
+ if (caret) caret.remove();
227
+ term.insertAdjacentHTML("beforeend", render(text));
228
+ const c = document.createElement("span");
229
+ c.className = "caret";
230
+ c.innerHTML = "&nbsp;";
231
+ term.appendChild(c);
232
+ term.scrollTop = term.scrollHeight;
233
+ }
234
+
235
+ function freeze() {
236
+ const caret = term.querySelector(".caret");
237
+ if (caret) caret.remove();
238
+ }
239
+
240
+ let busy = false;
241
+
242
+ async function send() {
243
+ const text = input.value.trim();
244
+ if (!text || busy) return;
245
+ busy = true; sendBtn.disabled = true; clearBtn.disabled = true;
246
+ try {
247
+ messages.push({ role: "user", content: text });
248
+ input.value = "";
249
+
250
+ const client = await Client.connect(window.location.origin);
251
+ let streamedText = ""; // accumulating stream buffer we can re-render
252
+ let rendered = ""; // text already appended to terminal
253
+
254
+ // First paint: user line + "thinking…" prompt
255
+ const prefix =
256
+ `${ANSI.grey}$ ${ANSI.reset}${ANSI.green}user${ANSI.reset} ${ANSI.dim}Β»${ANSI.reset} ${text.replace(/\n/g, " ")}\n` +
257
+ `${ANSI.cyan}laguna${ANSI.reset}${ANSI.grey}@${ANSI.reset}${ANSI.yellow}openrouter${ANSI.reset}${ANSI.grey}:${ANSI.reset}${ANSI.dim}~${ANSI.reset} ${ANSI.grey}thinking…${ANSI.reset}\n`;
258
+ appendText(prefix);
259
+
260
+ const result = await client.predict("/chat", {
261
+ messages, reasoning: reasoning.checked,
262
+ });
263
+
264
+ const events = (result && result.data && result.data[0]) || [];
265
+ let assistantRecord = null;
266
+ for (const ev of events) {
267
+ if (!ev || typeof ev !== "object") continue;
268
+ if (ev.event === "delta" && typeof ev.text === "string") appendText(ev.text);
269
+ else if (ev.event === "done") assistantRecord = ev.assistant || null;
270
+ }
271
+ freeze();
272
+
273
+ // Persist the assistant turn (with reasoning_details) into local history
274
+ // so the next call resumes the same chain of thought.
275
+ if (assistantRecord && assistantRecord.content !== undefined) {
276
+ messages.push(assistantRecord);
277
+ } else {
278
+ // fallback: snapshot whatever we saw, no reasoning_details
279
+ messages.push({ role: "assistant", content: "" });
280
+ }
281
+ } catch (e) {
282
+ freeze();
283
+ appendText(`\n${ANSI.bold}${ANSI.red}error${ANSI.reset} ${ANSI.dim}Β»${ANSI.reset} ${e && e.message || e}\n`);
284
+ } finally {
285
+ busy = false; sendBtn.disabled = false; clearBtn.disabled = false;
286
+ input.focus();
287
+ }
288
+ }
289
+
290
+ sendBtn.addEventListener("click", send);
291
+ input.addEventListener("keydown", (e) => {
292
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
293
+ });
294
+ clearBtn.addEventListener("click", () => {
295
+ messages = [];
296
+ term.innerHTML = render(banner);
297
+ });
298
+ </script>
299
+ </body>
300
+ </html>
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ openai>=1.0
2
+ gradio==6.20.0