Lucien-shark commited on
Commit
6d66684
·
verified ·
1 Parent(s): 1f7c785

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +226 -448
app.py CHANGED
@@ -2,12 +2,11 @@ import os
2
  import re
3
  import json
4
  import time
5
- import shutil
6
  import torch
7
  import torch.nn as nn
8
  import gradio as gr
9
  from pathlib import Path
10
- from collections import deque # ADDED for penalty window
11
 
12
  # ─────────────────────────────────────────
13
  # 🔐 Admin password
@@ -23,7 +22,6 @@ CONFIG_PATH = SPACE_ROOT / "config.json"
23
  _pt_files = sorted(SPACE_ROOT.glob("*.pt"))
24
  MODEL_PATH = _pt_files[0] if _pt_files else SPACE_ROOT / "default_model.pt"
25
 
26
- # Find tokenizer — any .json that isn't config.json
27
  _tok_files = [f for f in SPACE_ROOT.glob("*.json") if f.name != "config.json"]
28
  TOKENIZER_PATH = _tok_files[0] if _tok_files else None
29
 
@@ -31,7 +29,7 @@ TOKENIZER_PATH = _tok_files[0] if _tok_files else None
31
  # 🗃️ Config
32
  # ─────────────────────────────────────────
33
  DEFAULT_CONFIG = {
34
- "model_type": "char", # "char" or "token"
35
  "hidden_layers": 5,
36
  "neurons": 768,
37
  "embed_size": 384,
@@ -42,17 +40,16 @@ DEFAULT_CONFIG = {
42
  "bot_tag": "### Response:",
43
  "eos_token": "<|end|>",
44
  "system_prompt": "You are a helpful and intelligent AI assistant named Linny.",
45
- "default_temp": 0.7,
46
- "default_penalty": 1.10, # CHANGED: from 1.2 to 1.10 (better default)
47
- "default_penalty_window": 110, # ADDED
48
- "default_top_p": 0.4, # CHANGED: from 0.9 to 0.4 (matches local)
49
- "default_top_k": 65, # CHANGED: from 50 to 65 (matches local)
50
- "default_max_len": 1575,
51
- "min_response_tokens": 3, # ADDED
52
- "max_reasoning_tokens": 2500, # ADDED
53
- # reasoning_mode: "prompt_suffix" appends /think | "response_prefix" prefills <think>
54
- "reasoning_mode": "prompt_suffix",
55
- "reasoning_start": True,
56
  }
57
 
58
  def load_config() -> dict:
@@ -68,7 +65,6 @@ def save_config(cfg: dict):
68
  with open(CONFIG_PATH, "w") as f:
69
  json.dump(cfg, f, indent=2)
70
 
71
-
72
  # ─────────────────────────────────────────
73
  # 🧠 Model Architectures (unchanged)
74
  # ─────────────────────────────────────────
@@ -77,32 +73,25 @@ class LSTMCharLM(nn.Module):
77
  super().__init__()
78
  self.embed = nn.Embedding(vocab_size, embed_size)
79
  self.lstm = nn.LSTM(embed_size, hidden_size, num_layers=num_layers,
80
- batch_first=True,
81
- dropout=dropout if num_layers > 1 else 0)
82
- self.fc = nn.Linear(hidden_size, vocab_size)
83
-
84
  def forward(self, x, hidden=None):
85
- e = self.embed(x)
86
- out, hidden = self.lstm(e, hidden)
87
  return self.fc(out), hidden
88
 
89
-
90
  class LSTMTokenLM(nn.Module):
91
  def __init__(self, vocab_size, embed_size, hidden_size, num_layers, dropout=0.2):
92
  super().__init__()
93
  self.embed = nn.Embedding(vocab_size, embed_size)
94
  self.lstm = nn.LSTM(embed_size, hidden_size, num_layers=num_layers,
95
- batch_first=True,
96
- dropout=dropout if num_layers > 1 else 0)
97
- self.fc = nn.Linear(hidden_size, vocab_size)
98
-
99
  def forward(self, x, hidden=None):
100
  out, hidden = self.lstm(self.embed(x), hidden)
101
  return self.fc(out), hidden
102
 
103
-
104
  # ─────────────────────────────────────────
105
- # 🔤 GPT-2 byte decoder (token mode only)
106
  # ─────────────────────────────────────────
107
  def _build_byte_decoder():
108
  bs = (list(range(ord('!'), ord('~')+1)) +
@@ -113,7 +102,7 @@ def _build_byte_decoder():
113
  for b in range(256):
114
  if b not in bs:
115
  bs.append(b)
116
- cs.append(256 + n)
117
  n += 1
118
  return {chr(c): b for b, c in zip(bs, cs)}
119
 
@@ -125,122 +114,60 @@ def _tok_to_bytes(tok_str):
125
  except KeyError:
126
  return tok_str.encode('utf-8', errors='replace')
127
 
128
-
129
  # ─────────────────────────────────────────
130
- # ⚙️ Model Loader (UPDATED with caching and optimizations)
131
  # ─────────────────────────────────────────
132
  class LinnyModel:
133
  def __init__(self, pt_path, config: dict, tokenizer_path=None):
134
  self.config = config
135
  self.model_type = config.get("model_type", "char")
136
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
137
-
138
- # ADDED: Cache for hidden states to speed up repeated prefixes
139
- self.cached_hidden = None
140
- self.cached_prefix = None
141
- self.cached_prefix_ids = None
142
 
143
  ckpt = torch.load(pt_path, map_location=self.device, weights_only=False)
144
 
145
  if self.model_type == "token":
146
- try:
147
- from tokenizers import Tokenizer as HFTokenizer
148
- except ImportError:
149
- raise ImportError("Install tokenizers: pip install tokenizers")
150
-
151
  tok_path = tokenizer_path or config.get("tokenizer_path")
152
  if not tok_path or not Path(str(tok_path)).exists():
153
  raise FileNotFoundError(f"Tokenizer not found: {tok_path}")
154
-
155
  self.tokenizer = HFTokenizer.from_file(str(tok_path))
156
- vocab_size = self.tokenizer.get_vocab_size()
157
- self.chars = None
158
- self.stoi = None
159
- self.itos = None
160
-
161
- arch = ckpt.get('config', {})
162
  layers = arch.get('hidden_layers', config['hidden_layers'])
163
  neurons = arch.get('neurons', config['neurons'])
164
  embed = arch.get('embed_size', config['embed_size'])
165
  dropout = arch.get('dropout', config.get('dropout', 0.2))
166
-
167
  self.model = LSTMTokenLM(vocab_size, embed, neurons, layers, dropout).to(self.device)
168
  self.model.load_state_dict(ckpt['model_state'])
169
-
170
  else:
171
  self.tokenizer = None
172
- self.chars = ckpt["chars"]
173
- self.stoi = {ch: i for i, ch in enumerate(self.chars)}
174
- self.itos = {i: ch for i, ch in enumerate(self.chars)}
175
-
176
  dropout = ckpt.get("config", {}).get("dropout", config.get("dropout", 0.2))
177
- self.model = LSTMCharLM(
178
- len(self.chars),
179
- config["embed_size"],
180
- config["neurons"],
181
- config["hidden_layers"],
182
- dropout,
183
- ).to(self.device)
184
  self.model.load_state_dict(ckpt["model_state"])
185
 
186
- # ADDED: Try to use torch.compile for speed (PyTorch 2.0+)
187
- if hasattr(torch, 'compile') and self.device == torch.device("cpu"):
188
- try:
189
- self.model = torch.compile(self.model, mode="reduce-overhead")
190
- print("✅ TorchScript compilation enabled for CPU speedup")
191
- except:
192
- print("⚠️ Torch.compile failed, using regular model")
193
-
194
  self.model.eval()
195
  self.epoch = ckpt.get('epoch', '?')
196
 
197
- def _prime_hidden(self, text, hidden):
198
- if self.model_type == "token":
199
- ids = self.tokenizer.encode(text).ids
200
- else:
201
- ids = [self.stoi.get(c, 0) for c in text]
202
- t = torch.tensor([ids], dtype=torch.long).to(self.device)
203
- with torch.no_grad():
204
- _, hidden = self.model(t, hidden)
205
- return hidden, torch.tensor([[ids[-1]]], dtype=torch.long).to(self.device)
206
-
207
- # ADDED: Method to get cached hidden state for repeated prompts
208
- def get_cached_prefix(self, prefix_text):
209
- """Cache hidden state for a prefix to avoid recomputation"""
210
- if self.cached_prefix == prefix_text:
211
- return self.cached_hidden, self.cached_prefix_ids
212
-
213
- if self.model_type == "token":
214
- ids = self.tokenizer.encode(prefix_text).ids
215
- else:
216
- ids = [self.stoi.get(c, 0) for c in prefix_text]
217
-
218
- if not ids:
219
- return None, None
220
-
221
- t = torch.tensor([ids], dtype=torch.long).to(self.device)
222
- with torch.no_grad():
223
- _, hidden = self.model(t, None)
224
-
225
- self.cached_prefix = prefix_text
226
- self.cached_hidden = hidden
227
- self.cached_prefix_ids = torch.tensor([[ids[-1]]], dtype=torch.long).to(self.device)
228
-
229
- return hidden, self.cached_prefix_ids
230
-
231
- def stream_generate(self, prompt, temperature=0.7, max_len=1575,
232
- penalty=1.2, penalty_window=110, # ADDED penalty_window
233
- top_p=0.9, top_k=50, force_thinking=False,
234
- prefix_text="", penalize_prefix=False, # ADDED for continue feature
235
- min_response_tokens=3, # ADDED
236
- max_reasoning_tokens=2500): # ADDED
237
  """
238
- Generate with proper repetition penalty, reasoning tracking, and continue support.
239
  """
240
- cfg = self.config
241
  user_tag = cfg.get("user_tag", "### Instruction:")
242
- bot_tag = cfg.get("bot_tag", "### Response:")
243
- r_mode = cfg.get("reasoning_mode", "prompt_suffix")
244
  eos_token_str = cfg.get("eos_token", "<|end|>")
245
 
246
  # Apply prompt_suffix mode
@@ -250,32 +177,22 @@ class LinnyModel:
250
  actual_prompt = prompt.strip() + " /think"
251
 
252
  formatted = f"{user_tag}\n{actual_prompt}\n\n{bot_tag}\n"
253
-
 
254
  working_memory = cfg.get("working_memory", 0)
255
  if working_memory > 0:
256
  max_len = max(50, min(max_len, working_memory - len(formatted)))
257
 
258
  hidden = None
259
  generated = ""
260
-
261
- # ADDED: Track recent tokens for penalty
262
  recent_tokens = deque(maxlen=penalty_window)
263
-
264
- # ADDED: Track reasoning state
265
  in_reasoning = False
266
  think_closed = False
267
  awaiting_response = False
268
  response_token_count = 0
269
  reasoning_toks = 0
270
-
271
- # Get token IDs for special tokens (token mode only)
272
- eos_id = None
273
- think_open_id = None
274
- think_close_id = None
275
- if self.model_type == "token":
276
- eos_id = self.tokenizer.token_to_id(eos_token_str)
277
- think_open_id = self.tokenizer.token_to_id("<think>")
278
- think_close_id = self.tokenizer.token_to_id("</think>")
279
 
280
  with torch.no_grad():
281
  # Encode the conversation prefix
@@ -284,44 +201,47 @@ class LinnyModel:
284
  else:
285
  ids = [self.stoi.get(c, 0) for c in formatted]
286
 
287
- t = torch.tensor([ids], dtype=torch.long).to(self.device)
288
  _, hidden = self.model(t, hidden)
289
- input_token = torch.tensor([[ids[-1]]], dtype=torch.long).to(self.device)
290
-
291
- # If we have existing assistant response (prefix_text for continue mode)
292
  if prefix_text:
293
  if self.model_type == "token":
294
  prefix_ids = self.tokenizer.encode(prefix_text).ids
295
  else:
296
  prefix_ids = [self.stoi.get(c, 0) for c in prefix_text]
297
-
298
  if prefix_ids:
299
- pt = torch.tensor([prefix_ids], dtype=torch.long).to(self.device)
300
  _, hidden = self.model(pt, hidden)
301
  generated = prefix_text
302
- input_token = torch.tensor([[prefix_ids[-1]]], dtype=torch.long).to(self.device)
303
  if penalize_prefix:
304
  recent_tokens.extend(prefix_ids)
305
-
306
  # Update state based on prefix
307
  if "<think>" in prefix_text and "</think>" not in prefix_text:
308
  in_reasoning = True
309
- think_closed = False
310
  elif "</think>" in prefix_text:
311
  think_closed = True
312
  awaiting_response = True
313
 
314
- # Prefill <think> if in response_prefix mode (only if no prefix)
315
  if not prefix_text and force_thinking and r_mode == "response_prefix":
316
- if self.model_type == "token" and think_open_id is not None:
317
- tt = torch.tensor([[think_open_id]], dtype=torch.long).to(self.device)
 
 
 
 
 
 
 
 
 
 
 
318
  _, hidden = self.model(tt, hidden)
319
- input_token = tt
320
- generated = "<think>"
321
- yield "<think>"
322
- in_reasoning = True
323
- elif self.model_type == "char":
324
- hidden, input_token = self._prime_hidden("<think>", hidden)
325
  generated = "<think>"
326
  for ch in "<think>":
327
  yield ch
@@ -330,25 +250,40 @@ class LinnyModel:
330
  if cfg.get("reasoning_start", False) and not prefix_text:
331
  prefix = f"I need to think about this. The user said '{prompt}'"
332
  if self.model_type == "token":
333
- hidden, input_token = self._prime_hidden(prefix, hidden)
 
 
 
334
  generated += prefix
335
  yield prefix
336
  else:
337
- hidden, input_token = self._prime_hidden(prefix, hidden)
338
- generated += prefix
339
  for ch in prefix:
 
 
 
 
 
340
  yield ch
341
 
342
- # ── Token generation ──
 
 
 
 
 
 
 
 
 
 
 
343
  if self.model_type == "token":
344
  byte_buf = b""
345
- token_count = 0
346
-
347
  for step in range(max_len):
348
  logits, hidden = self.model(input_token, hidden)
349
  lf = logits[0, -1].float() / max(temperature, 1e-8)
350
 
351
- # FIXED: Proper repetition penalty
352
  if penalty != 1.0 and len(recent_tokens) > 0:
353
  penalized_ids = set(recent_tokens)
354
  for token_id in penalized_ids:
@@ -369,16 +304,14 @@ class LinnyModel:
369
  lf[si[rm]] = float("-inf")
370
 
371
  nxt = torch.multinomial(torch.softmax(lf, dim=-1), 1).item()
372
- token_count += 1
373
 
374
  # EOS handling with forced minimum response
375
- if eos_id is not None and nxt == eos_id:
376
  if awaiting_response and response_token_count < min_response_tokens:
377
- continue # Not enough response yet, skip EOS
378
  else:
379
  break
380
 
381
- # Update recent tokens for penalty
382
  recent_tokens.append(nxt)
383
 
384
  # Update reasoning state
@@ -390,24 +323,22 @@ class LinnyModel:
390
  think_closed = True
391
  awaiting_response = True
392
  response_token_count = 0
393
-
394
  if in_reasoning and not think_closed:
395
  reasoning_toks += 1
396
- if max_reasoning_tokens and max_reasoning_tokens > 0 and reasoning_toks >= max_reasoning_tokens:
397
  # Force close think
398
  if byte_buf:
399
  decoded = byte_buf.decode('utf-8', errors='replace')
400
  generated += decoded
401
  yield decoded
402
  byte_buf = b""
403
- # Yield closing tag
404
  yield "</think>"
405
  in_reasoning = False
406
  think_closed = True
407
  awaiting_response = True
408
  response_token_count = 0
409
- # Feed the closing token
410
- ct = torch.tensor([[think_close_id]], dtype=torch.long).to(self.device)
411
  _, hidden = self.model(ct, hidden)
412
  input_token = ct
413
  recent_tokens.append(think_close_id)
@@ -418,7 +349,7 @@ class LinnyModel:
418
  elif not in_reasoning and not think_closed:
419
  response_token_count += 1
420
 
421
- # Send token
422
  tok_str = self.tokenizer.id_to_token(nxt) or ""
423
  byte_buf += _tok_to_bytes(tok_str)
424
  try:
@@ -428,36 +359,31 @@ class LinnyModel:
428
  byte_buf = b""
429
  except UnicodeDecodeError:
430
  pass
431
-
432
- input_token = torch.tensor([[nxt]], dtype=torch.long).to(self.device)
433
-
434
  if byte_buf:
435
  leftover = byte_buf.decode('utf-8', errors='replace')
436
  generated += leftover
437
  yield leftover
438
-
439
- # Signal if we hit the token limit (for UI "keep generating" button)
440
- if token_count >= max_len - 5:
441
- yield "__HIT_LIMIT__"
442
 
443
- # ── Char generation (UPDATED with proper penalty) ──
 
 
444
  else:
445
- token_count = 0
446
  for step in range(max_len):
447
  logits, hidden = self.model(input_token, hidden)
448
  lf = logits[0, -1].float() / max(temperature, 1e-8)
449
-
450
- # FIXED: Proper repetition penalty for char mode
451
  if penalty != 1.0 and len(recent_tokens) > 0:
452
- # Convert recent chars to indices
453
- for ch in set(recent_tokens):
454
- if isinstance(ch, str):
455
- idx = self.stoi.get(ch, 0)
456
- else:
457
- idx = ch
458
  if idx < lf.size(0):
459
  lf[idx] /= penalty
460
-
461
  if top_k > 0:
462
  tv, _ = torch.topk(lf, min(top_k, lf.size(-1)))
463
  lf[lf < tv[-1]] = float("-inf")
@@ -468,28 +394,19 @@ class LinnyModel:
468
  rm[..., 1:] = rm[..., :-1].clone()
469
  rm[..., 0] = False
470
  lf[si[rm]] = float("-inf")
471
-
472
  idx = torch.multinomial(torch.softmax(lf, dim=-1), 1).item()
473
  char = self.itos[idx]
474
- token_count += 1
475
-
476
- # Update recent tokens (store char for penalty)
477
  recent_tokens.append(char)
478
-
479
- input_token = torch.tensor([[idx]], dtype=torch.long).to(self.device)
480
  if char == "#" and generated.endswith("##"):
481
  break
482
-
483
  generated += char
484
  yield char
485
-
486
- # Signal hit limit for char mode too
487
- if token_count >= max_len - 5:
488
- yield "__HIT_LIMIT__"
489
-
490
 
491
  # ───────────────��─────────────────────────
492
- # 🧰 Helpers (unchanged)
493
  # ─────────────────────────────────────────
494
  def parse_think_tags(text: str):
495
  if "<think>" not in text:
@@ -500,9 +417,7 @@ def parse_think_tags(text: str):
500
  return inner.strip(), (before + after).strip(), True
501
  return rest.strip(), before.strip(), False
502
 
503
-
504
  def extract_current_topic(thinking_text: str) -> str:
505
- """Grab the most recent **Topic** marker from thinking text."""
506
  if not thinking_text:
507
  return "Reasoning..."
508
  matches = re.findall(r'\*\*([^*]+)\*\*', thinking_text)
@@ -510,51 +425,41 @@ def extract_current_topic(thinking_text: str) -> str:
510
  return f"Reasoning: {matches[-1].strip()}"
511
  return "Reasoning..."
512
 
513
-
514
  def format_message(visible: str, thinking: str | None,
515
  think_complete: bool, think_elapsed: float | None,
516
  current_topic: str = "Reasoning...") -> str:
517
  if not thinking:
518
  return visible
519
-
520
  if think_complete and think_elapsed is not None:
521
- summary = f"💭 Thought for {think_elapsed:.1f}s"
522
- open_attr = "" # collapsed when done
523
  else:
524
- summary = current_topic
525
- open_attr = "" # always collapsed — user opens manually
526
-
527
- think_block = (
528
- f"<details class='think-details'{open_attr}>"
529
- f"<summary class='think-summary'>{summary}</summary>"
530
- f"<div class='think-content'>{thinking}</div>"
531
- f"</details>"
532
- )
533
  if visible.strip():
534
  return think_block + "\n\n" + visible
535
  return think_block
536
 
537
-
538
  def extract_html_canvas(text: str):
539
  pattern = r"```html\s*\n([\s\S]*?)```"
540
- match = re.search(pattern, text, re.IGNORECASE)
541
  if match:
542
- html_code = match.group(1)
543
  cleaned_text = text[:match.start()] + text[match.end():]
544
  return html_code.strip(), cleaned_text.strip()
545
  return None, text
546
 
547
-
548
  def make_canvas_html(code: str) -> str:
549
  escaped = code.replace('"', "&quot;")
550
- return (
551
- "<div class='canvas-wrapper'>"
552
- "<div class='canvas-label'>🖼️ HTML Canvas</div>"
553
- f'<iframe class="canvas-frame" srcdoc="{escaped}" '
554
- 'sandbox="allow-scripts" scrolling="auto"></iframe>'
555
- "</div>"
556
- )
557
-
558
 
559
  # ─────────────────────────────────────────
560
  # 🚀 Auto-load
@@ -565,24 +470,18 @@ _startup_msg = "⚠️ No model found. Place your `.pt` in the Space root."
565
 
566
  if MODEL_PATH.exists():
567
  try:
568
- _startup_cfg = load_config()
569
- _startup_model = LinnyModel(MODEL_PATH, _startup_cfg,
570
- tokenizer_path=TOKENIZER_PATH)
571
- wm = _startup_cfg.get("working_memory", 0)
572
  mtype = _startup_cfg.get("model_type", "char")
573
  epoch = _startup_model.epoch
574
- _startup_msg = (
575
- f" Model auto-loaded! "
576
- f"({_startup_cfg['hidden_layers']}L × {_startup_cfg['neurons']}N, "
577
- f"epoch {epoch}, {mtype}"
578
- + (f", {wm} ctx)" if wm else ")")
579
- )
580
  print(_startup_msg)
581
  except Exception as e:
582
  _startup_msg = f"❌ Auto-load failed: {e}"
583
  print(_startup_msg)
584
 
585
-
586
  # ─────────────────────────────────────────
587
  # 🎨 CSS (unchanged)
588
  # ─────────────────────────────────────────
@@ -610,15 +509,12 @@ details[open] .think-summary::before { content: "▼ "; }
610
  .tab-nav { background: #0f0f1a !important; border-bottom: 1px solid #2d2d4e !important; }
611
  """
612
 
613
-
614
  # ─────────────────────────────────────────
615
- # 🖥️ UI (UPDATED with penalty window slider)
616
  # ─────────────────────────────────────────
617
  def build_ui():
618
  cfg = load_config()
619
-
620
  with gr.Blocks(title="Linny AI", css=CSS) as demo:
621
-
622
  session_model = gr.State(_startup_model)
623
  session_cfg = gr.State(_startup_cfg)
624
 
@@ -630,168 +526,97 @@ def build_ui():
630
  """)
631
 
632
  with gr.Tabs():
633
-
634
- # ══════════════════════════════════════════
635
- # TAB 1 — Chat
636
- # ══════════════════════════════════════════
637
  with gr.TabItem("💬 Chat"):
638
-
639
- model_status = gr.Markdown(
640
- value=_startup_msg,
641
- elem_classes=["status-bar"],
642
- )
643
-
644
- chatbot = gr.Chatbot(
645
- elem_id="chatbox",
646
- label="",
647
- render_markdown=True,
648
- )
649
-
650
  canvas_display = gr.HTML(visible=False)
651
 
652
  with gr.Row(elem_classes=["input-row"]):
653
- msg_box = gr.Textbox(
654
- placeholder="Message Linny…",
655
- show_label=False,
656
- scale=8,
657
- lines=1,
658
- autofocus=True,
659
- )
660
  send_btn = gr.Button("Send ↩", variant="primary", scale=1)
661
 
662
  with gr.Row(elem_classes=["reasoning-row"]):
663
- reasoning_toggle = gr.Checkbox(
664
- value=False,
665
- label="🧠 Force Reasoning (pre-fills <think> token)",
666
- scale=1,
667
- )
668
 
669
  with gr.Accordion("⚙️ Generation Settings", open=False):
670
  with gr.Row():
671
- temp_sl = gr.Slider(0.1, 1.5, value=cfg.get("default_temp", 0.7), step=0.05, label="Temperature")
672
- penalty_sl = gr.Slider(1.0, 2.0, value=cfg.get("default_penalty", 1.10), step=0.05, label="Repetition Penalty")
673
  with gr.Row():
674
- penalty_window_sl = gr.Slider(10, 500, value=cfg.get("default_penalty_window", 110), step=10, label="Penalty Window (tokens)") # ADDED
675
  with gr.Row():
676
- topp_sl = gr.Slider(0.0, 1.0, value=cfg.get("default_top_p", 0.4), step=0.05, label="Top-P")
677
- topk_sl = gr.Slider(0, 100, value=cfg.get("default_top_k", 65), step=1, label="Top-K (0 = off)")
678
- max_len_sl = gr.Slider(100, 8000, value=cfg.get("default_max_len", 1575), step=50,
679
- label="Max Response Length (auto-capped by Working Memory)")
680
 
681
  with gr.Accordion("📤 Upload Your Own Model (optional)", open=False):
682
  gr.Markdown("Upload a `.pt` file and optionally a tokenizer `.json` for token-based models.")
683
  with gr.Row():
684
- user_pt = gr.File(label="Upload .pt file", file_types=[".pt"])
685
  user_tok = gr.File(label="Upload tokenizer .json (token model only)", file_types=[".json"])
686
-
687
- model_type_radio = gr.Radio(
688
- choices=["char", "token"], value="char",
689
- label="Model Type",
690
- )
691
-
692
  with gr.Row():
693
- u_layers = gr.Number(value=5, label="Hidden Layers", precision=0)
694
- u_neurons = gr.Number(value=768, label="Neurons", precision=0)
695
- u_embed = gr.Number(value=384, label="Embed Size", precision=0)
696
- u_dropout = gr.Number(value=0.2, label="Dropout", precision=2)
697
  u_working_mem = gr.Number(value=2048, label="Working Memory (0=off)", precision=0)
698
  with gr.Row():
699
  u_user_tag = gr.Textbox(value="### Instruction:", label="User Tag")
700
- u_bot_tag = gr.Textbox(value="### Response:", label="Bot Tag")
701
- u_eos = gr.Textbox(value="<|end|>", label="EOS Token")
702
-
703
- load_user_btn = gr.Button("🚀 Load My Model", variant="secondary")
704
  user_load_status = gr.Markdown("")
705
 
706
- # ══════════════════════════════════════════
707
- # TAB 2 — Admin (UPDATED with new settings)
708
- # ══════════════════════════════════════════
709
  with gr.TabItem("🔒 Admin"):
710
- gr.Markdown(
711
- "## Admin Panel\n"
712
- "Edit `config.json` settings. Upload `default_model.pt` via HF Files tab."
713
- )
714
-
715
  with gr.Row():
716
- admin_pw = gr.Textbox(label="Admin Password", type="password",
717
- placeholder="Enter password", scale=3)
718
  admin_login_btn = gr.Button("Login", scale=1)
719
  admin_status = gr.Markdown("")
720
-
721
  with gr.Group(visible=False) as admin_panel:
722
  gr.Markdown("### Model Architecture")
723
- model_type_admin = gr.Radio(
724
- choices=["char", "token"],
725
- value=cfg.get("model_type", "char"),
726
- label="Model Type",
727
- )
728
  with gr.Row():
729
- a_layers = gr.Number(value=cfg.get("hidden_layers", 5), label="Hidden Layers", precision=0)
730
- a_neurons = gr.Number(value=cfg.get("neurons", 768), label="Neurons", precision=0)
731
- a_embed = gr.Number(value=cfg.get("embed_size", 384), label="Embed Size", precision=0)
732
- a_dropout = gr.Number(value=cfg.get("dropout", 0.2), label="Dropout", precision=2)
733
- a_working_mem = gr.Number(value=cfg.get("working_memory", 2048),
734
- label="Working Memory (0=off)", precision=0)
735
 
736
  gr.Markdown("### Tags & Tokens")
737
  with gr.Row():
738
- a_user_tag = gr.Textbox(value=cfg.get("user_tag", "### Instruction:"), label="User Tag")
739
- a_bot_tag = gr.Textbox(value=cfg.get("bot_tag", "### Response:"), label="Bot Tag")
740
- a_eos = gr.Textbox(value=cfg.get("eos_token", "<|end|>"), label="EOS Token")
741
- a_sys = gr.Textbox(
742
- value=cfg.get("system_prompt", "You are a helpful AI named Linny."),
743
- label="System Prompt", lines=2,
744
- )
745
 
746
  gr.Markdown("### Reasoning")
747
- a_reasoning_mode = gr.Radio(
748
- choices=["prompt_suffix", "response_prefix"],
749
- value=cfg.get("reasoning_mode", "prompt_suffix"),
750
- label="Force Reasoning Mode",
751
- info="prompt_suffix = appends /think to prompt | response_prefix = prefills <think> token",
752
- )
753
- a_reasoning_start = gr.Checkbox(
754
- value=cfg.get("reasoning_start", True),
755
- label="Enable Reasoning Start Prefix",
756
- info="Pre-fills 'I need to think about this...' after <think>",
757
- )
758
- # ADDED: New reasoning limits
759
- a_min_response_tokens = gr.Number(
760
- value=cfg.get("min_response_tokens", 3),
761
- label="Minimum Response Tokens After </think>",
762
- precision=0
763
- )
764
- a_max_reasoning_tokens = gr.Number(
765
- value=cfg.get("max_reasoning_tokens", 2500),
766
- label="Max Reasoning Tokens (auto-close if exceeded)",
767
- precision=0
768
- )
769
 
770
  gr.Markdown("### Default Generation Settings")
771
  with gr.Row():
772
- a_temp = gr.Slider(0.1, 1.5, value=cfg.get("default_temp", 0.7), step=0.05, label="Default Temperature")
773
- a_penalty = gr.Slider(1.0, 2.0, value=cfg.get("default_penalty", 1.10), step=0.05, label="Default Penalty")
774
  with gr.Row():
775
  a_penalty_window = gr.Slider(10, 500, value=cfg.get("default_penalty_window", 110), step=10, label="Default Penalty Window")
776
  with gr.Row():
777
- a_topp = gr.Slider(0.0, 1.0, value=cfg.get("default_top_p", 0.4), step=0.05, label="Default Top-P")
778
- a_topk = gr.Slider(0, 100, value=cfg.get("default_top_k", 65), step=1, label="Default Top-K")
779
- a_maxlen = gr.Slider(100, 8000, value=cfg.get("default_max_len", 1575), step=50, label="Default Max Length")
780
 
781
  save_cfg_btn = gr.Button("💾 Save config.json", variant="primary")
782
- save_status = gr.Markdown("")
783
-
784
- # ════════════════════════════════════════════════
785
- # Callbacks (UPDATED with new fields)
786
- # ════════════════════════════════════════════════
787
 
 
788
  def do_admin_login(pw):
789
  if pw == ADMIN_PASSWORD:
790
  return gr.update(visible=True), "✅ Logged in."
791
  return gr.update(visible=False), "❌ Incorrect password."
792
-
793
- admin_login_btn.click(do_admin_login, inputs=[admin_pw],
794
- outputs=[admin_panel, admin_status])
795
 
796
  def do_save_config(mtype, layers, neurons, embed, dropout, working_mem,
797
  user_tag, bot_tag, eos, sys_prompt,
@@ -800,94 +625,83 @@ def build_ui():
800
  temp, penalty, penalty_window, top_p, top_k, max_len):
801
  try:
802
  new_cfg = {
803
- "model_type": mtype,
804
- "hidden_layers": int(layers),
805
- "neurons": int(neurons),
806
- "embed_size": int(embed),
807
- "dropout": float(dropout),
808
- "working_memory": int(working_mem),
809
- "user_tag": user_tag,
810
- "bot_tag": bot_tag,
811
- "eos_token": eos,
812
- "system_prompt": sys_prompt,
813
- "reasoning_mode": reasoning_mode,
814
  "reasoning_start": bool(reasoning_start),
815
  "min_response_tokens": int(min_response_tokens),
816
  "max_reasoning_tokens": int(max_reasoning_tokens),
817
- "default_temp": float(temp),
818
  "default_penalty": float(penalty),
819
  "default_penalty_window": int(penalty_window),
820
- "default_top_p": float(top_p),
821
- "default_top_k": int(top_k),
822
  "default_max_len": int(max_len),
823
  }
824
  save_config(new_cfg)
825
- return "✅ config.json saved! Restart the Space to apply architecture changes."
826
  except Exception as e:
827
  return f"❌ Error: {e}"
828
-
829
- save_cfg_btn.click(
830
- do_save_config,
831
  inputs=[model_type_admin, a_layers, a_neurons, a_embed, a_dropout, a_working_mem,
832
  a_user_tag, a_bot_tag, a_eos, a_sys,
833
  a_reasoning_mode, a_reasoning_start,
834
  a_min_response_tokens, a_max_reasoning_tokens,
835
  a_temp, a_penalty, a_penalty_window, a_topp, a_topk, a_maxlen],
836
- outputs=[save_status],
837
- )
838
 
839
  def load_user_model(pt_file, tok_file, mtype, layers, neurons, embed, dropout,
840
- working_mem, user_tag, bot_tag, eos):
841
  if pt_file is None:
842
  return None, None, "❌ Please upload a .pt file first."
843
  try:
844
  user_cfg = {
845
- "model_type": mtype,
846
- "hidden_layers": int(layers),
847
- "neurons": int(neurons),
848
- "embed_size": int(embed),
849
- "dropout": float(dropout),
850
  "working_memory": int(working_mem),
851
- "user_tag": user_tag,
852
- "bot_tag": bot_tag,
853
- "eos_token": eos,
854
- "reasoning_mode": cfg.get("reasoning_mode", "prompt_suffix"),
855
- "reasoning_start": cfg.get("reasoning_start", True),
856
  "min_response_tokens": cfg.get("min_response_tokens", 3),
857
  "max_reasoning_tokens": cfg.get("max_reasoning_tokens", 2500),
858
  }
859
  tok_path = tok_file.name if tok_file else None
860
- m = LinnyModel(pt_file.name, user_cfg, tokenizer_path=tok_path)
861
- wm = user_cfg["working_memory"]
862
- return m, user_cfg, f"✅ Your model loaded! ({user_cfg['hidden_layers']}L × {user_cfg['neurons']}N, {mtype}, {wm} ctx, epoch {m.epoch})"
863
  except Exception as e:
864
  return None, None, f"❌ Error: {e}"
865
-
866
- load_user_btn.click(
867
- load_user_model,
868
  inputs=[user_pt, user_tok, model_type_radio,
869
  u_layers, u_neurons, u_embed, u_dropout, u_working_mem,
870
  u_user_tag, u_bot_tag, u_eos],
871
- outputs=[session_model, session_cfg, user_load_status],
872
- )
873
 
874
  def respond(message, history, model, temp, penalty, penalty_window, top_p, top_k, max_len, force_thinking):
875
  if not message.strip():
876
  yield history, "", gr.update(visible=False)
877
  return
878
-
879
  if model is None:
880
- yield history + [{"role":"user","content":message},{"role":"assistant","content":"⚠️ No model loaded. Place `default_model.pt` in the Space root, then restart."}], "", gr.update(visible=False)
881
  return
882
 
883
  history = history + [{"role":"user","content":message},{"role":"assistant","content":""}]
884
-
885
- eos = model.config.get("eos_token", "<|end|>")
886
  full_response = ""
887
- stop = False
888
- think_start = None
889
- think_end = None
890
- hit_limit = False # ADDED: track if we hit token limit
891
 
892
  try:
893
  for chunk in model.stream_generate(
@@ -895,58 +709,36 @@ def build_ui():
895
  temperature=float(temp),
896
  max_len=int(max_len),
897
  penalty=float(penalty),
898
- penalty_window=int(penalty_window), # ADDED
899
  top_p=float(top_p),
900
  top_k=int(top_k),
901
  force_thinking=bool(force_thinking),
902
- min_response_tokens=model.config.get("min_response_tokens", 3), # ADDED
903
- max_reasoning_tokens=model.config.get("max_reasoning_tokens", 2500), # ADDED
904
  ):
905
- # Check for hit limit marker
906
- if chunk == "__HIT_LIMIT__":
907
- hit_limit = True
908
- continue
909
-
910
  full_response += chunk
911
-
912
- if eos in full_response:
913
- full_response = full_response[:full_response.find(eos)]
 
914
  if full_response.count("<think>") > full_response.count("</think>"):
915
  full_response += "</think>"
916
  else:
917
  stop = True
918
 
919
  thinking, visible, think_complete = parse_think_tags(full_response)
920
-
921
- # Timer: start on first thinking char, STOP when </think> closes
922
  if thinking and think_start is None:
923
  think_start = time.time()
924
  if think_complete and think_end is None and think_start is not None:
925
  think_end = time.time()
926
-
927
- if think_end is not None:
928
- think_elapsed = think_end - think_start
929
- elif think_start is not None:
930
- think_elapsed = time.time() - think_start
931
- else:
932
- think_elapsed = None
933
-
934
  current_topic = extract_current_topic(thinking) if thinking else "Reasoning..."
935
-
936
  html_code, visible_clean = extract_html_canvas(visible)
937
- canvas_update = gr.update(
938
- visible=bool(html_code),
939
- value=make_canvas_html(html_code) if html_code else "",
940
- )
941
-
942
- history[-1] = {"role":"assistant","content":format_message(
943
- visible_clean, thinking, think_complete, think_elapsed, current_topic
944
- )}
945
  yield history, "", canvas_update
946
-
947
  if stop:
948
  break
949
-
950
  except Exception as e:
951
  history[-1] = {"role":"assistant","content":f"⚠️ Generation error: {e}"}
952
  yield history, "", gr.update(visible=False)
@@ -957,35 +749,21 @@ def build_ui():
957
  if think_end is None and think_start is not None:
958
  think_end = time.time()
959
  think_elapsed = (think_end - think_start) if (think_end and think_start) else None
960
-
961
  if think_complete and not visible.strip():
962
  visible = "*(no response generated)*"
963
-
964
- # ADDED: Add note if hit token limit
965
- if hit_limit:
966
- visible += "\n\n---\n⚠️ *Response reached token limit. You can continue generating if this feature is implemented in UI.*"
967
-
968
  current_topic = extract_current_topic(thinking) if thinking else "Reasoning..."
969
  html_code, visible_clean = extract_html_canvas(visible)
970
- history[-1] = {"role":"assistant","content":format_message(
971
- visible_clean, thinking, think_complete=True,
972
- think_elapsed=think_elapsed, current_topic=current_topic
973
- )}
974
- canvas_update = gr.update(
975
- visible=bool(html_code),
976
- value=make_canvas_html(html_code) if html_code else "",
977
- )
978
  yield history, "", canvas_update
979
 
980
- shared_inputs = [msg_box, chatbot, session_model,
981
- temp_sl, penalty_sl, penalty_window_sl, topp_sl, topk_sl, max_len_sl, reasoning_toggle]
982
  shared_outputs = [chatbot, msg_box, canvas_display]
983
-
984
- send_btn.click(respond, inputs=shared_inputs, outputs=shared_outputs)
985
  msg_box.submit(respond, inputs=shared_inputs, outputs=shared_outputs)
986
 
987
  return demo
988
 
989
-
990
  if __name__ == "__main__":
991
  build_ui().launch(ssr_mode=False)
 
2
  import re
3
  import json
4
  import time
 
5
  import torch
6
  import torch.nn as nn
7
  import gradio as gr
8
  from pathlib import Path
9
+ from collections import deque
10
 
11
  # ─────────────────────────────────────────
12
  # 🔐 Admin password
 
22
  _pt_files = sorted(SPACE_ROOT.glob("*.pt"))
23
  MODEL_PATH = _pt_files[0] if _pt_files else SPACE_ROOT / "default_model.pt"
24
 
 
25
  _tok_files = [f for f in SPACE_ROOT.glob("*.json") if f.name != "config.json"]
26
  TOKENIZER_PATH = _tok_files[0] if _tok_files else None
27
 
 
29
  # 🗃️ Config
30
  # ─────────────────────────────────────────
31
  DEFAULT_CONFIG = {
32
+ "model_type": "char",
33
  "hidden_layers": 5,
34
  "neurons": 768,
35
  "embed_size": 384,
 
40
  "bot_tag": "### Response:",
41
  "eos_token": "<|end|>",
42
  "system_prompt": "You are a helpful and intelligent AI assistant named Linny.",
43
+ "default_temp": 0.8,
44
+ "default_penalty": 1.05,
45
+ "default_penalty_window": 110,
46
+ "default_top_p": 0.4,
47
+ "default_top_k": 65,
48
+ "default_max_len": 2600,
49
+ "reasoning_mode": "response_prefix",
50
+ "reasoning_start": False,
51
+ "min_response_tokens": 3,
52
+ "max_reasoning_tokens": 2500,
 
53
  }
54
 
55
  def load_config() -> dict:
 
65
  with open(CONFIG_PATH, "w") as f:
66
  json.dump(cfg, f, indent=2)
67
 
 
68
  # ─────────────────────────────────────────
69
  # 🧠 Model Architectures (unchanged)
70
  # ─────────────────────────────────────────
 
73
  super().__init__()
74
  self.embed = nn.Embedding(vocab_size, embed_size)
75
  self.lstm = nn.LSTM(embed_size, hidden_size, num_layers=num_layers,
76
+ batch_first=True, dropout=dropout if num_layers > 1 else 0)
77
+ self.fc = nn.Linear(hidden_size, vocab_size)
 
 
78
  def forward(self, x, hidden=None):
79
+ out, hidden = self.lstm(self.embed(x), hidden)
 
80
  return self.fc(out), hidden
81
 
 
82
  class LSTMTokenLM(nn.Module):
83
  def __init__(self, vocab_size, embed_size, hidden_size, num_layers, dropout=0.2):
84
  super().__init__()
85
  self.embed = nn.Embedding(vocab_size, embed_size)
86
  self.lstm = nn.LSTM(embed_size, hidden_size, num_layers=num_layers,
87
+ batch_first=True, dropout=dropout if num_layers > 1 else 0)
88
+ self.fc = nn.Linear(hidden_size, vocab_size)
 
 
89
  def forward(self, x, hidden=None):
90
  out, hidden = self.lstm(self.embed(x), hidden)
91
  return self.fc(out), hidden
92
 
 
93
  # ─────────────────────────────────────────
94
+ # 🔤 GPT-2 byte decoder
95
  # ─────────────────────────────────────────
96
  def _build_byte_decoder():
97
  bs = (list(range(ord('!'), ord('~')+1)) +
 
102
  for b in range(256):
103
  if b not in bs:
104
  bs.append(b)
105
+ cs.append(256+n)
106
  n += 1
107
  return {chr(c): b for b, c in zip(bs, cs)}
108
 
 
114
  except KeyError:
115
  return tok_str.encode('utf-8', errors='replace')
116
 
 
117
  # ─────────────────────────────────────────
118
+ # ⚙️ Model Loader (identical generation to local)
119
  # ─────────────────────────────────────────
120
  class LinnyModel:
121
  def __init__(self, pt_path, config: dict, tokenizer_path=None):
122
  self.config = config
123
  self.model_type = config.get("model_type", "char")
124
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
 
 
 
 
125
 
126
  ckpt = torch.load(pt_path, map_location=self.device, weights_only=False)
127
 
128
  if self.model_type == "token":
129
+ from tokenizers import Tokenizer as HFTokenizer
 
 
 
 
130
  tok_path = tokenizer_path or config.get("tokenizer_path")
131
  if not tok_path or not Path(str(tok_path)).exists():
132
  raise FileNotFoundError(f"Tokenizer not found: {tok_path}")
 
133
  self.tokenizer = HFTokenizer.from_file(str(tok_path))
134
+ vocab_size = self.tokenizer.get_vocab_size()
135
+ self.chars = None; self.stoi = None; self.itos = None
136
+ arch = ckpt.get('config', {})
 
 
 
137
  layers = arch.get('hidden_layers', config['hidden_layers'])
138
  neurons = arch.get('neurons', config['neurons'])
139
  embed = arch.get('embed_size', config['embed_size'])
140
  dropout = arch.get('dropout', config.get('dropout', 0.2))
 
141
  self.model = LSTMTokenLM(vocab_size, embed, neurons, layers, dropout).to(self.device)
142
  self.model.load_state_dict(ckpt['model_state'])
 
143
  else:
144
  self.tokenizer = None
145
+ self.chars = ckpt["chars"]
146
+ self.stoi = {ch: i for i, ch in enumerate(self.chars)}
147
+ self.itos = {i: ch for i, ch in enumerate(self.chars)}
 
148
  dropout = ckpt.get("config", {}).get("dropout", config.get("dropout", 0.2))
149
+ self.model = LSTMCharLM(len(self.chars), config["embed_size"],
150
+ config["neurons"], config["hidden_layers"], dropout).to(self.device)
 
 
 
 
 
151
  self.model.load_state_dict(ckpt["model_state"])
152
 
 
 
 
 
 
 
 
 
153
  self.model.eval()
154
  self.epoch = ckpt.get('epoch', '?')
155
 
156
+ # ------------------------------------------------------------------
157
+ # Exact copy of local server's generate_stream logic
158
+ # ------------------------------------------------------------------
159
+ def stream_generate(self, prompt, temperature=0.8, max_len=2600,
160
+ penalty=1.05, penalty_window=110,
161
+ top_p=0.4, top_k=65, force_thinking=False,
162
+ prefix_text="", penalize_prefix=False,
163
+ min_response_tokens=3, max_reasoning_tokens=2500):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  """
165
+ Matches local linny_server.py generate_stream exactly.
166
  """
167
+ cfg = self.config
168
  user_tag = cfg.get("user_tag", "### Instruction:")
169
+ bot_tag = cfg.get("bot_tag", "### Response:")
170
+ r_mode = cfg.get("reasoning_mode", "response_prefix")
171
  eos_token_str = cfg.get("eos_token", "<|end|>")
172
 
173
  # Apply prompt_suffix mode
 
177
  actual_prompt = prompt.strip() + " /think"
178
 
179
  formatted = f"{user_tag}\n{actual_prompt}\n\n{bot_tag}\n"
180
+
181
+ # Working memory cap
182
  working_memory = cfg.get("working_memory", 0)
183
  if working_memory > 0:
184
  max_len = max(50, min(max_len, working_memory - len(formatted)))
185
 
186
  hidden = None
187
  generated = ""
 
 
188
  recent_tokens = deque(maxlen=penalty_window)
189
+
190
+ # State tracking (exactly as local)
191
  in_reasoning = False
192
  think_closed = False
193
  awaiting_response = False
194
  response_token_count = 0
195
  reasoning_toks = 0
 
 
 
 
 
 
 
 
 
196
 
197
  with torch.no_grad():
198
  # Encode the conversation prefix
 
201
  else:
202
  ids = [self.stoi.get(c, 0) for c in formatted]
203
 
204
+ t = torch.tensor([ids], dtype=torch.long, device=self.device)
205
  _, hidden = self.model(t, hidden)
206
+ input_token = torch.tensor([[ids[-1]]], dtype=torch.long, device=self.device)
207
+
208
+ # If we have existing assistant response (continue mode)
209
  if prefix_text:
210
  if self.model_type == "token":
211
  prefix_ids = self.tokenizer.encode(prefix_text).ids
212
  else:
213
  prefix_ids = [self.stoi.get(c, 0) for c in prefix_text]
 
214
  if prefix_ids:
215
+ pt = torch.tensor([prefix_ids], dtype=torch.long, device=self.device)
216
  _, hidden = self.model(pt, hidden)
217
  generated = prefix_text
218
+ input_token = torch.tensor([[prefix_ids[-1]]], dtype=torch.long, device=self.device)
219
  if penalize_prefix:
220
  recent_tokens.extend(prefix_ids)
 
221
  # Update state based on prefix
222
  if "<think>" in prefix_text and "</think>" not in prefix_text:
223
  in_reasoning = True
 
224
  elif "</think>" in prefix_text:
225
  think_closed = True
226
  awaiting_response = True
227
 
228
+ # Prefill <think> if in response_prefix mode and no prefix
229
  if not prefix_text and force_thinking and r_mode == "response_prefix":
230
+ if self.model_type == "token":
231
+ think_id = self.tokenizer.token_to_id("<think>")
232
+ if think_id is not None:
233
+ tt = torch.tensor([[think_id]], dtype=torch.long, device=self.device)
234
+ _, hidden = self.model(tt, hidden)
235
+ input_token = tt
236
+ generated = "<think>"
237
+ yield "<think>"
238
+ in_reasoning = True
239
+ else:
240
+ # Char mode: prime hidden with "<think>"
241
+ think_ids = [self.stoi.get(ch, 0) for ch in "<think>"]
242
+ tt = torch.tensor([think_ids], dtype=torch.long, device=self.device)
243
  _, hidden = self.model(tt, hidden)
244
+ input_token = torch.tensor([[think_ids[-1]]], dtype=torch.long, device=self.device)
 
 
 
 
 
245
  generated = "<think>"
246
  for ch in "<think>":
247
  yield ch
 
250
  if cfg.get("reasoning_start", False) and not prefix_text:
251
  prefix = f"I need to think about this. The user said '{prompt}'"
252
  if self.model_type == "token":
253
+ pids = self.tokenizer.encode(prefix).ids
254
+ pt = torch.tensor([pids], dtype=torch.long, device=self.device)
255
+ _, hidden = self.model(pt, hidden)
256
+ input_token = torch.tensor([[pids[-1]]], dtype=torch.long, device=self.device)
257
  generated += prefix
258
  yield prefix
259
  else:
 
 
260
  for ch in prefix:
261
+ idx = self.stoi.get(ch, 0)
262
+ it = torch.tensor([[idx]], dtype=torch.long, device=self.device)
263
+ _, hidden = self.model(it, hidden)
264
+ input_token = it
265
+ generated += ch
266
  yield ch
267
 
268
+ # Get special token IDs
269
+ eos_id = None
270
+ think_open_id = None
271
+ think_close_id = None
272
+ if self.model_type == "token":
273
+ eos_id = self.tokenizer.token_to_id(eos_token_str)
274
+ think_open_id = self.tokenizer.token_to_id("<think>")
275
+ think_close_id = self.tokenizer.token_to_id("</think>")
276
+
277
+ # ------------------------------------------------------------------
278
+ # Token generation (exactly as local)
279
+ # ------------------------------------------------------------------
280
  if self.model_type == "token":
281
  byte_buf = b""
 
 
282
  for step in range(max_len):
283
  logits, hidden = self.model(input_token, hidden)
284
  lf = logits[0, -1].float() / max(temperature, 1e-8)
285
 
286
+ # Repetition penalty
287
  if penalty != 1.0 and len(recent_tokens) > 0:
288
  penalized_ids = set(recent_tokens)
289
  for token_id in penalized_ids:
 
304
  lf[si[rm]] = float("-inf")
305
 
306
  nxt = torch.multinomial(torch.softmax(lf, dim=-1), 1).item()
 
307
 
308
  # EOS handling with forced minimum response
309
+ if nxt == eos_id:
310
  if awaiting_response and response_token_count < min_response_tokens:
311
+ continue
312
  else:
313
  break
314
 
 
315
  recent_tokens.append(nxt)
316
 
317
  # Update reasoning state
 
323
  think_closed = True
324
  awaiting_response = True
325
  response_token_count = 0
326
+
327
  if in_reasoning and not think_closed:
328
  reasoning_toks += 1
329
+ if max_reasoning_tokens and reasoning_toks >= max_reasoning_tokens:
330
  # Force close think
331
  if byte_buf:
332
  decoded = byte_buf.decode('utf-8', errors='replace')
333
  generated += decoded
334
  yield decoded
335
  byte_buf = b""
 
336
  yield "</think>"
337
  in_reasoning = False
338
  think_closed = True
339
  awaiting_response = True
340
  response_token_count = 0
341
+ ct = torch.tensor([[think_close_id]], dtype=torch.long, device=self.device)
 
342
  _, hidden = self.model(ct, hidden)
343
  input_token = ct
344
  recent_tokens.append(think_close_id)
 
349
  elif not in_reasoning and not think_closed:
350
  response_token_count += 1
351
 
352
+ # Output token
353
  tok_str = self.tokenizer.id_to_token(nxt) or ""
354
  byte_buf += _tok_to_bytes(tok_str)
355
  try:
 
359
  byte_buf = b""
360
  except UnicodeDecodeError:
361
  pass
362
+
363
+ input_token = torch.tensor([[nxt]], dtype=torch.long, device=self.device)
364
+
365
  if byte_buf:
366
  leftover = byte_buf.decode('utf-8', errors='replace')
367
  generated += leftover
368
  yield leftover
 
 
 
 
369
 
370
+ # ------------------------------------------------------------------
371
+ # Character generation (exactly as local)
372
+ # ------------------------------------------------------------------
373
  else:
 
374
  for step in range(max_len):
375
  logits, hidden = self.model(input_token, hidden)
376
  lf = logits[0, -1].float() / max(temperature, 1e-8)
377
+
378
+ # Repetition penalty on characters
379
  if penalty != 1.0 and len(recent_tokens) > 0:
380
+ # recent_tokens stores characters (strings)
381
+ penalized_chars = set(recent_tokens)
382
+ for ch in penalized_chars:
383
+ idx = self.stoi.get(ch, 0)
 
 
384
  if idx < lf.size(0):
385
  lf[idx] /= penalty
386
+
387
  if top_k > 0:
388
  tv, _ = torch.topk(lf, min(top_k, lf.size(-1)))
389
  lf[lf < tv[-1]] = float("-inf")
 
394
  rm[..., 1:] = rm[..., :-1].clone()
395
  rm[..., 0] = False
396
  lf[si[rm]] = float("-inf")
397
+
398
  idx = torch.multinomial(torch.softmax(lf, dim=-1), 1).item()
399
  char = self.itos[idx]
 
 
 
400
  recent_tokens.append(char)
401
+ input_token = torch.tensor([[idx]], dtype=torch.long, device=self.device)
402
+
403
  if char == "#" and generated.endswith("##"):
404
  break
 
405
  generated += char
406
  yield char
 
 
 
 
 
407
 
408
  # ───────────────��─────────────────────────
409
+ # 🧰 Helpers (unchanged from original HF)
410
  # ─────────────────────────────────────────
411
  def parse_think_tags(text: str):
412
  if "<think>" not in text:
 
417
  return inner.strip(), (before + after).strip(), True
418
  return rest.strip(), before.strip(), False
419
 
 
420
  def extract_current_topic(thinking_text: str) -> str:
 
421
  if not thinking_text:
422
  return "Reasoning..."
423
  matches = re.findall(r'\*\*([^*]+)\*\*', thinking_text)
 
425
  return f"Reasoning: {matches[-1].strip()}"
426
  return "Reasoning..."
427
 
 
428
  def format_message(visible: str, thinking: str | None,
429
  think_complete: bool, think_elapsed: float | None,
430
  current_topic: str = "Reasoning...") -> str:
431
  if not thinking:
432
  return visible
 
433
  if think_complete and think_elapsed is not None:
434
+ summary = f"💭 Thought for {think_elapsed:.1f}s"
435
+ open_attr = ""
436
  else:
437
+ summary = current_topic
438
+ open_attr = ""
439
+ think_block = (f"<details class='think-details'{open_attr}>"
440
+ f"<summary class='think-summary'>{summary}</summary>"
441
+ f"<div class='think-content'>{thinking}</div>"
442
+ f"</details>")
 
 
 
443
  if visible.strip():
444
  return think_block + "\n\n" + visible
445
  return think_block
446
 
 
447
  def extract_html_canvas(text: str):
448
  pattern = r"```html\s*\n([\s\S]*?)```"
449
+ match = re.search(pattern, text, re.IGNORECASE)
450
  if match:
451
+ html_code = match.group(1)
452
  cleaned_text = text[:match.start()] + text[match.end():]
453
  return html_code.strip(), cleaned_text.strip()
454
  return None, text
455
 
 
456
  def make_canvas_html(code: str) -> str:
457
  escaped = code.replace('"', "&quot;")
458
+ return (f"<div class='canvas-wrapper'>"
459
+ f"<div class='canvas-label'>🖼️ HTML Canvas</div>"
460
+ f'<iframe class="canvas-frame" srcdoc="{escaped}" '
461
+ f'sandbox="allow-scripts" scrolling="auto"></iframe>'
462
+ f"</div>")
 
 
 
463
 
464
  # ─────────────────────────────────────────
465
  # 🚀 Auto-load
 
470
 
471
  if MODEL_PATH.exists():
472
  try:
473
+ _startup_cfg = load_config()
474
+ _startup_model = LinnyModel(MODEL_PATH, _startup_cfg, tokenizer_path=TOKENIZER_PATH)
475
+ wm = _startup_cfg.get("working_memory", 0)
 
476
  mtype = _startup_cfg.get("model_type", "char")
477
  epoch = _startup_model.epoch
478
+ _startup_msg = (f"✅ Model auto-loaded! ({_startup_cfg['hidden_layers']}L × {_startup_cfg['neurons']}N, "
479
+ f"epoch {epoch}, {mtype}" + (f", {wm} ctx)" if wm else ")"))
 
 
 
 
480
  print(_startup_msg)
481
  except Exception as e:
482
  _startup_msg = f"❌ Auto-load failed: {e}"
483
  print(_startup_msg)
484
 
 
485
  # ─────────────────────────────────────────
486
  # 🎨 CSS (unchanged)
487
  # ─────────────────────────────────────────
 
509
  .tab-nav { background: #0f0f1a !important; border-bottom: 1px solid #2d2d4e !important; }
510
  """
511
 
 
512
  # ─────────────────────────────────────────
513
+ # 🖥️ UI (identical to original HF but with penalty window)
514
  # ─────────────────────────────────────────
515
  def build_ui():
516
  cfg = load_config()
 
517
  with gr.Blocks(title="Linny AI", css=CSS) as demo:
 
518
  session_model = gr.State(_startup_model)
519
  session_cfg = gr.State(_startup_cfg)
520
 
 
526
  """)
527
 
528
  with gr.Tabs():
 
 
 
 
529
  with gr.TabItem("💬 Chat"):
530
+ model_status = gr.Markdown(value=_startup_msg, elem_classes=["status-bar"])
531
+ chatbot = gr.Chatbot(elem_id="chatbox", label="", render_markdown=True)
 
 
 
 
 
 
 
 
 
 
532
  canvas_display = gr.HTML(visible=False)
533
 
534
  with gr.Row(elem_classes=["input-row"]):
535
+ msg_box = gr.Textbox(placeholder="Message Linny…", show_label=False, scale=8, lines=1, autofocus=True)
 
 
 
 
 
 
536
  send_btn = gr.Button("Send ↩", variant="primary", scale=1)
537
 
538
  with gr.Row(elem_classes=["reasoning-row"]):
539
+ reasoning_toggle = gr.Checkbox(value=False, label="🧠 Force Reasoning (pre-fills <think> token)", scale=1)
 
 
 
 
540
 
541
  with gr.Accordion("⚙️ Generation Settings", open=False):
542
  with gr.Row():
543
+ temp_sl = gr.Slider(0.1, 1.5, value=cfg.get("default_temp", 0.8), step=0.05, label="Temperature")
544
+ penalty_sl = gr.Slider(1.0, 2.0, value=cfg.get("default_penalty", 1.05), step=0.05, label="Repetition Penalty")
545
  with gr.Row():
546
+ penalty_window_sl = gr.Slider(10, 500, value=cfg.get("default_penalty_window", 110), step=10, label="Penalty Window")
547
  with gr.Row():
548
+ topp_sl = gr.Slider(0.0, 1.0, value=cfg.get("default_top_p", 0.4), step=0.05, label="Top-P")
549
+ topk_sl = gr.Slider(0, 100, value=cfg.get("default_top_k", 65), step=1, label="Top-K (0 = off)")
550
+ max_len_sl = gr.Slider(100, 8000, value=cfg.get("default_max_len", 2600), step=50, label="Max Response Length")
 
551
 
552
  with gr.Accordion("📤 Upload Your Own Model (optional)", open=False):
553
  gr.Markdown("Upload a `.pt` file and optionally a tokenizer `.json` for token-based models.")
554
  with gr.Row():
555
+ user_pt = gr.File(label="Upload .pt file", file_types=[".pt"])
556
  user_tok = gr.File(label="Upload tokenizer .json (token model only)", file_types=[".json"])
557
+ model_type_radio = gr.Radio(choices=["char", "token"], value="char", label="Model Type")
 
 
 
 
 
558
  with gr.Row():
559
+ u_layers = gr.Number(value=5, label="Hidden Layers", precision=0)
560
+ u_neurons = gr.Number(value=768, label="Neurons", precision=0)
561
+ u_embed = gr.Number(value=384, label="Embed Size", precision=0)
562
+ u_dropout = gr.Number(value=0.2, label="Dropout", precision=2)
563
  u_working_mem = gr.Number(value=2048, label="Working Memory (0=off)", precision=0)
564
  with gr.Row():
565
  u_user_tag = gr.Textbox(value="### Instruction:", label="User Tag")
566
+ u_bot_tag = gr.Textbox(value="### Response:", label="Bot Tag")
567
+ u_eos = gr.Textbox(value="<|end|>", label="EOS Token")
568
+ load_user_btn = gr.Button("🚀 Load My Model", variant="secondary")
 
569
  user_load_status = gr.Markdown("")
570
 
 
 
 
571
  with gr.TabItem("🔒 Admin"):
572
+ gr.Markdown("## Admin Panel\nEdit `config.json` settings.")
 
 
 
 
573
  with gr.Row():
574
+ admin_pw = gr.Textbox(label="Admin Password", type="password", placeholder="Enter password", scale=3)
 
575
  admin_login_btn = gr.Button("Login", scale=1)
576
  admin_status = gr.Markdown("")
 
577
  with gr.Group(visible=False) as admin_panel:
578
  gr.Markdown("### Model Architecture")
579
+ model_type_admin = gr.Radio(choices=["char", "token"], value=cfg.get("model_type", "char"), label="Model Type")
 
 
 
 
580
  with gr.Row():
581
+ a_layers = gr.Number(value=cfg.get("hidden_layers", 5), label="Hidden Layers", precision=0)
582
+ a_neurons = gr.Number(value=cfg.get("neurons", 768), label="Neurons", precision=0)
583
+ a_embed = gr.Number(value=cfg.get("embed_size", 384), label="Embed Size", precision=0)
584
+ a_dropout = gr.Number(value=cfg.get("dropout", 0.2), label="Dropout", precision=2)
585
+ a_working_mem = gr.Number(value=cfg.get("working_memory", 2048), label="Working Memory (0=off)", precision=0)
 
586
 
587
  gr.Markdown("### Tags & Tokens")
588
  with gr.Row():
589
+ a_user_tag = gr.Textbox(value=cfg.get("user_tag", "### Instruction:"), label="User Tag")
590
+ a_bot_tag = gr.Textbox(value=cfg.get("bot_tag", "### Response:"), label="Bot Tag")
591
+ a_eos = gr.Textbox(value=cfg.get("eos_token", "<|end|>"), label="EOS Token")
592
+ a_sys = gr.Textbox(value=cfg.get("system_prompt", "You are a helpful AI named Linny."), label="System Prompt", lines=2)
 
 
 
593
 
594
  gr.Markdown("### Reasoning")
595
+ a_reasoning_mode = gr.Radio(choices=["prompt_suffix", "response_prefix"], value=cfg.get("reasoning_mode", "response_prefix"), label="Force Reasoning Mode")
596
+ a_reasoning_start = gr.Checkbox(value=cfg.get("reasoning_start", False), label="Enable Reasoning Start Prefix")
597
+ a_min_response_tokens = gr.Number(value=cfg.get("min_response_tokens", 3), label="Minimum Response Tokens After </think>", precision=0)
598
+ a_max_reasoning_tokens = gr.Number(value=cfg.get("max_reasoning_tokens", 2500), label="Max Reasoning Tokens", precision=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
 
600
  gr.Markdown("### Default Generation Settings")
601
  with gr.Row():
602
+ a_temp = gr.Slider(0.1, 1.5, value=cfg.get("default_temp", 0.8), step=0.05, label="Default Temperature")
603
+ a_penalty = gr.Slider(1.0, 2.0, value=cfg.get("default_penalty", 1.05), step=0.05, label="Default Penalty")
604
  with gr.Row():
605
  a_penalty_window = gr.Slider(10, 500, value=cfg.get("default_penalty_window", 110), step=10, label="Default Penalty Window")
606
  with gr.Row():
607
+ a_topp = gr.Slider(0.0, 1.0, value=cfg.get("default_top_p", 0.4), step=0.05, label="Default Top-P")
608
+ a_topk = gr.Slider(0, 100, value=cfg.get("default_top_k", 65), step=1, label="Default Top-K")
609
+ a_maxlen = gr.Slider(100, 8000, value=cfg.get("default_max_len", 2600), step=50, label="Default Max Length")
610
 
611
  save_cfg_btn = gr.Button("💾 Save config.json", variant="primary")
612
+ save_status = gr.Markdown("")
 
 
 
 
613
 
614
+ # Callbacks
615
  def do_admin_login(pw):
616
  if pw == ADMIN_PASSWORD:
617
  return gr.update(visible=True), "✅ Logged in."
618
  return gr.update(visible=False), "❌ Incorrect password."
619
+ admin_login_btn.click(do_admin_login, inputs=[admin_pw], outputs=[admin_panel, admin_status])
 
 
620
 
621
  def do_save_config(mtype, layers, neurons, embed, dropout, working_mem,
622
  user_tag, bot_tag, eos, sys_prompt,
 
625
  temp, penalty, penalty_window, top_p, top_k, max_len):
626
  try:
627
  new_cfg = {
628
+ "model_type": mtype,
629
+ "hidden_layers": int(layers),
630
+ "neurons": int(neurons),
631
+ "embed_size": int(embed),
632
+ "dropout": float(dropout),
633
+ "working_memory": int(working_mem),
634
+ "user_tag": user_tag,
635
+ "bot_tag": bot_tag,
636
+ "eos_token": eos,
637
+ "system_prompt": sys_prompt,
638
+ "reasoning_mode": reasoning_mode,
639
  "reasoning_start": bool(reasoning_start),
640
  "min_response_tokens": int(min_response_tokens),
641
  "max_reasoning_tokens": int(max_reasoning_tokens),
642
+ "default_temp": float(temp),
643
  "default_penalty": float(penalty),
644
  "default_penalty_window": int(penalty_window),
645
+ "default_top_p": float(top_p),
646
+ "default_top_k": int(top_k),
647
  "default_max_len": int(max_len),
648
  }
649
  save_config(new_cfg)
650
+ return "✅ config.json saved! Restart the Space to apply changes."
651
  except Exception as e:
652
  return f"❌ Error: {e}"
653
+ save_cfg_btn.click(do_save_config,
 
 
654
  inputs=[model_type_admin, a_layers, a_neurons, a_embed, a_dropout, a_working_mem,
655
  a_user_tag, a_bot_tag, a_eos, a_sys,
656
  a_reasoning_mode, a_reasoning_start,
657
  a_min_response_tokens, a_max_reasoning_tokens,
658
  a_temp, a_penalty, a_penalty_window, a_topp, a_topk, a_maxlen],
659
+ outputs=[save_status])
 
660
 
661
  def load_user_model(pt_file, tok_file, mtype, layers, neurons, embed, dropout,
662
+ working_mem, user_tag, bot_tag, eos):
663
  if pt_file is None:
664
  return None, None, "❌ Please upload a .pt file first."
665
  try:
666
  user_cfg = {
667
+ "model_type": mtype,
668
+ "hidden_layers": int(layers),
669
+ "neurons": int(neurons),
670
+ "embed_size": int(embed),
671
+ "dropout": float(dropout),
672
  "working_memory": int(working_mem),
673
+ "user_tag": user_tag,
674
+ "bot_tag": bot_tag,
675
+ "eos_token": eos,
676
+ "reasoning_mode": cfg.get("reasoning_mode", "response_prefix"),
677
+ "reasoning_start": cfg.get("reasoning_start", False),
678
  "min_response_tokens": cfg.get("min_response_tokens", 3),
679
  "max_reasoning_tokens": cfg.get("max_reasoning_tokens", 2500),
680
  }
681
  tok_path = tok_file.name if tok_file else None
682
+ m = LinnyModel(pt_file.name, user_cfg, tokenizer_path=tok_path)
683
+ return m, user_cfg, f"✅ Model loaded! ({user_cfg['hidden_layers']}L × {user_cfg['neurons']}N, {mtype}, epoch {m.epoch})"
 
684
  except Exception as e:
685
  return None, None, f"❌ Error: {e}"
686
+ load_user_btn.click(load_user_model,
 
 
687
  inputs=[user_pt, user_tok, model_type_radio,
688
  u_layers, u_neurons, u_embed, u_dropout, u_working_mem,
689
  u_user_tag, u_bot_tag, u_eos],
690
+ outputs=[session_model, session_cfg, user_load_status])
 
691
 
692
  def respond(message, history, model, temp, penalty, penalty_window, top_p, top_k, max_len, force_thinking):
693
  if not message.strip():
694
  yield history, "", gr.update(visible=False)
695
  return
 
696
  if model is None:
697
+ yield history + [{"role":"user","content":message},{"role":"assistant","content":"⚠️ No model loaded."}], "", gr.update(visible=False)
698
  return
699
 
700
  history = history + [{"role":"user","content":message},{"role":"assistant","content":""}]
 
 
701
  full_response = ""
702
+ stop = False
703
+ think_start = None
704
+ think_end = None
 
705
 
706
  try:
707
  for chunk in model.stream_generate(
 
709
  temperature=float(temp),
710
  max_len=int(max_len),
711
  penalty=float(penalty),
712
+ penalty_window=int(penalty_window),
713
  top_p=float(top_p),
714
  top_k=int(top_k),
715
  force_thinking=bool(force_thinking),
716
+ min_response_tokens=model.config.get("min_response_tokens", 3),
717
+ max_reasoning_tokens=model.config.get("max_reasoning_tokens", 2500),
718
  ):
 
 
 
 
 
719
  full_response += chunk
720
+ # Stop at EOS if present
721
+ eos_str = model.config.get("eos_token", "<|end|>")
722
+ if eos_str in full_response:
723
+ full_response = full_response[:full_response.find(eos_str)]
724
  if full_response.count("<think>") > full_response.count("</think>"):
725
  full_response += "</think>"
726
  else:
727
  stop = True
728
 
729
  thinking, visible, think_complete = parse_think_tags(full_response)
 
 
730
  if thinking and think_start is None:
731
  think_start = time.time()
732
  if think_complete and think_end is None and think_start is not None:
733
  think_end = time.time()
734
+ think_elapsed = (think_end - think_start) if (think_end and think_start) else (time.time() - think_start if think_start else None)
 
 
 
 
 
 
 
735
  current_topic = extract_current_topic(thinking) if thinking else "Reasoning..."
 
736
  html_code, visible_clean = extract_html_canvas(visible)
737
+ canvas_update = gr.update(visible=bool(html_code), value=make_canvas_html(html_code) if html_code else "")
738
+ history[-1] = {"role":"assistant","content":format_message(visible_clean, thinking, think_complete, think_elapsed, current_topic)}
 
 
 
 
 
 
739
  yield history, "", canvas_update
 
740
  if stop:
741
  break
 
742
  except Exception as e:
743
  history[-1] = {"role":"assistant","content":f"⚠️ Generation error: {e}"}
744
  yield history, "", gr.update(visible=False)
 
749
  if think_end is None and think_start is not None:
750
  think_end = time.time()
751
  think_elapsed = (think_end - think_start) if (think_end and think_start) else None
 
752
  if think_complete and not visible.strip():
753
  visible = "*(no response generated)*"
 
 
 
 
 
754
  current_topic = extract_current_topic(thinking) if thinking else "Reasoning..."
755
  html_code, visible_clean = extract_html_canvas(visible)
756
+ history[-1] = {"role":"assistant","content":format_message(visible_clean, thinking, True, think_elapsed, current_topic)}
757
+ canvas_update = gr.update(visible=bool(html_code), value=make_canvas_html(html_code) if html_code else "")
 
 
 
 
 
 
758
  yield history, "", canvas_update
759
 
760
+ shared_inputs = [msg_box, chatbot, session_model,
761
+ temp_sl, penalty_sl, penalty_window_sl, topp_sl, topk_sl, max_len_sl, reasoning_toggle]
762
  shared_outputs = [chatbot, msg_box, canvas_display]
763
+ send_btn.click(respond, inputs=shared_inputs, outputs=shared_outputs)
 
764
  msg_box.submit(respond, inputs=shared_inputs, outputs=shared_outputs)
765
 
766
  return demo
767
 
 
768
  if __name__ == "__main__":
769
  build_ui().launch(ssr_mode=False)