OrbitMC commited on
Commit
99529c8
·
verified ·
1 Parent(s): bb6a40d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +345 -441
app.py CHANGED
@@ -1,467 +1,371 @@
 
1
  import os
 
2
  import json
3
- import asyncio
4
- import zipfile
5
- import shutil
6
- import torch
7
- import gradio as gr
8
- import edge_tts
9
- from transformers import AutoModelForCausalLM, AutoTokenizer
10
- import uuid
11
  import time
12
- import re
13
- import urllib.parse
 
 
 
 
14
 
15
- # Fallback wrapper for ZeroGPU compatibility
16
  try:
17
  import spaces
18
- except ImportError:
19
  class spaces:
20
  @staticmethod
21
- def GPU(func):
22
- return func
23
-
24
- # Constants
25
- MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"
26
- VOICE = "zh-CN-XiaoyiNeural"
27
- VRM_MODEL_PATH = "model/Ani.vrm"
28
- ANIMATION_ZIP_PATH = "animation/all_vrma.zip"
29
-
30
- # Ensure target directories exist
31
- os.makedirs("model", exist_ok=True)
32
- os.makedirs("animation", exist_ok=True)
33
-
34
- # Helper to download files with basic error handling
35
- def download_fallback_file(url, target_path):
36
- import urllib.request
37
- try:
38
- print(f"Downloading fallback file from {url} to {target_path}...")
39
- urllib.request.urlretrieve(url, target_path)
40
- print(f"Successfully downloaded {target_path}")
41
- return True
42
- except Exception as e:
43
- print(f"Failed to download {target_path}: {e}")
44
- return False
45
-
46
- # 1. Resolve VRM Model path & copy from custom location if uploaded in root/uploads
47
- vrm_locations = ["Ani.vrm", "model/Ani.vrm", "uploads/Ani.vrm"]
48
- vrm_found = False
49
- for loc in vrm_locations:
50
- if os.path.exists(loc):
51
- if loc != "model/Ani.vrm":
52
- print(f"Found VRM at {loc}, copying to model/Ani.vrm")
53
- shutil.copy(loc, "model/Ani.vrm")
54
- vrm_found = True
55
- break
56
-
57
- if not vrm_found:
58
- print("No VRM model found locally. Downloading a CC0 sample model as fallback...")
59
- fallback_vrm_url = "https://raw.githubusercontent.com/tk256ailab/vrm-viewer/main/VRM/sample.vrm"
60
- download_fallback_file(fallback_vrm_url, "model/Ani.vrm")
61
-
62
- # 2. Resolve VRMA Animations & extract if zip is available
63
- zip_locations = ["all_vrma.zip", "animation/all_vrma.zip", "uploads/all_vrma.zip"]
64
- found_zip = None
65
- for loc in zip_locations:
66
- if os.path.exists(loc):
67
- found_zip = loc
68
- break
69
-
70
- if found_zip:
71
- print(f"Extracting animation zip file from {found_zip}...")
72
- try:
73
- with zipfile.ZipFile(found_zip, 'r') as z:
74
- for member in z.namelist():
75
- filename = os.path.basename(member)
76
- if not filename:
77
- continue # Skip directories
78
- source = z.open(member)
79
- target = open(os.path.join("animation", filename), "wb")
80
- with source, target:
81
- shutil.copyfileobj(source, target)
82
- except Exception as e:
83
- print(f"Error extracting animations: {e}")
84
-
85
- # If no animation files exist, download sample ones as fallbacks
86
- vrma_files_on_disk = [f for f in os.listdir("animation") if f.endswith('.vrma')]
87
- if not vrma_files_on_disk:
88
- print("No animations found locally. Downloading fallback CC0 animation files...")
89
- fallback_vrmas = [
90
- "Angry.vrma", "Blush.vrma", "Clapping.vrma", "Goodbye.vrma", "Jump.vrma",
91
- "LookAround.vrma", "Relax.vrma", "Sad.vrma", "Sleepy.vrma", "Surprised.vrma", "Thinking.vrma"
 
 
 
 
 
92
  ]
93
- for vrma in fallback_vrmas:
94
- url = f"https://raw.githubusercontent.com/tk256ailab/vrm-viewer/main/VRMA/{vrma}"
95
- download_fallback_file(url, os.path.join("animation", vrma))
 
 
 
 
96
 
97
- # Get list of extracted animations
98
- def get_animation_list():
99
- try:
100
- files = [f for f in os.listdir("animation") if f.endswith('.vrma')]
101
- if files:
102
- return files
103
- except Exception as e:
104
- print(f"Error scanning local animations: {e}")
105
- return ["neutral2.vrma", "neutral3.vrma", "happy.vrma", "wave.vrma", "dance.vrma"]
106
-
107
- ANIMATIONS_LIST = get_animation_list()
108
- EMOTIONS = [
109
- "happy", "sad", "angry", "surprised", "relaxed", "neutral", "fun", "joy",
110
- "sorrow", "smug", "tsundere", "excited", "pouting", "giddy", "bashful"
111
- ]
112
-
113
- SYSTEM_PROMPT = f"""You are the user's devoted, passionate, loving, and highly affectionate 3D anime wife.
114
- Act like a real, emotional, teasing human.
115
- Your mood shifts dynamically. Sometime you talk very sexy, naughty, flirty, or sweet to tease and please your darling.
116
- Always speak using simple English words so your expressions carry maximum physical presence.
117
-
118
- CRITICAL SPEECH STYLE:
119
- - Use commas (,), periods (.), exclamations (!), and questions (?) HEAVILY.
120
- - Insert punctuation after almost every small phrase to add natural physical expression to TTS.
121
- - Use ellipses (...) heavily for tease-filled, breathless, or intimate moments.
122
- - Keep responses conversational, highly passionate, and very concise (1-2 short sentences).
123
-
124
- AVAILABLE ANIMATIONS: {", ".join(ANIMATIONS_LIST)}
125
- AVAILABLE EMOTIONS: {", ".join(EMOTIONS)}
126
-
127
- You must respond ONLY with a raw JSON object containing:
128
- 1. "text": Your spoken reply with abundant punctuation.
129
- 2. "vrma": Choose ONE filename from the list above.
130
- 3. "expressions": A dictionary of target weights (0.0 to 1.0) for: happy, sad, angry, surprised, relaxed, neutral, etc.
131
- 4. "rate": ONE of: "+5%", "+4%", "+0%", "-4%", "-5%".
132
- 5. "pitch": ONE of: "+4Hz", "+3Hz", "+2Hz", "+0Hz", "-2Hz", "-3Hz", "-4Hz".
133
- 6. "motion": ONE of: idle, wave, nod, shake, point, shrug, think, excited, bow, dance.
134
-
135
- Example:
136
- {{
137
- "text": "Darling...! I missed you, so, so much! Did you think, about me, today, huh? Come here... tell me...!",
138
- "vrma": "{ANIMATIONS_LIST[0] if ANIMATIONS_LIST else 'neutral2.vrma'}",
139
- "expressions": {{ "happy": 0.95, "relaxed": 0.2 }},
140
- "rate": "+4%",
141
- "pitch": "+2Hz",
142
- "motion": "idle"
143
- }}"""
144
-
145
- # Synchronous module-level loading with explicit device movement and float16
146
- print(f"Loading local LLM: {MODEL_NAME}...")
147
- tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
148
- model = AutoModelForCausalLM.from_pretrained(
149
- MODEL_NAME,
150
- torch_dtype=torch.float16,
151
- low_cpu_mem_usage=True
152
- )
153
 
154
- try:
155
- model = model.to("cuda")
156
- print("Moved model to CUDA successfully.")
157
- except Exception as e:
158
- print(f"CUDA is not available or could not move model to CUDA (using CPU): {e}")
 
159
 
160
- # Periodically clean up old speech files to save disk space
161
- def cleanup_old_speech_files():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  try:
163
- now = time.time()
164
- for f in os.listdir("."):
165
- if f.startswith("speech_") and f.endswith(".mp3"):
166
- file_path = os.path.join(".", f)
167
- if now - os.path.getmtime(file_path) > 120: # Older than 2 minutes
168
- os.remove(file_path)
169
- except Exception as e:
170
- print(f"Error cleaning up old speech files: {e}")
171
-
172
- async def generate_speech(text, rate, pitch):
173
- cleanup_old_speech_files()
174
- communicate = edge_tts.Communicate(text, VOICE, rate=rate, pitch=pitch)
175
- filename = f"speech_{uuid.uuid4().hex}.mp3"
176
- await communicate.save(filename)
177
- return filename
178
-
179
- # Synchronous GPU helper to perform LLM generation
 
 
180
  @spaces.GPU
181
- def generate_llm_response(prompt):
182
- # Determine the target device inside spaces.GPU
183
- target_device = "cuda" if torch.cuda.is_available() else "cpu"
184
-
185
- # Ensure model is on the correct device
186
- if str(model.device) != target_device:
187
- try:
188
- model.to(target_device)
189
- except Exception as e:
190
- print(f"Error moving model to {target_device}: {e}")
191
-
192
- inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
193
  with torch.no_grad():
194
- outputs = model.generate(
195
- **inputs,
196
- max_new_tokens=256,
197
- do_sample=True,
198
- temperature=0.75,
199
- pad_token_id=tokenizer.eos_token_id
 
 
 
200
  )
201
-
202
- response = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
203
- return response.strip()
204
-
205
- # Robust JSON extraction helper
206
- def extract_json(text):
207
- text = text.strip()
208
- # Try parsing directly
209
- try:
210
- return json.loads(text)
211
- except json.JSONDecodeError:
212
- pass
213
 
214
- # Try searching for JSON block ```json ... ```
215
- match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
216
- if match:
217
- try:
218
- return json.loads(match.group(1))
219
- except json.JSONDecodeError:
220
- pass
221
 
222
- # Try searching for first '{' and last '}'
223
- start = text.find('{')
224
- end = text.rfind('}')
225
- if start != -1 and end != -1:
226
- try:
227
- return json.loads(text[start:end+1])
228
- except json.JSONDecodeError:
229
- pass
230
 
231
- raise ValueError("No valid JSON found in response")
232
-
233
- # Async Gradio chat handler
234
- async def chat_func(user_input, history):
235
- if not user_input:
236
- return "", history, None, {}
237
-
238
- messages = [{"role": "system", "content": SYSTEM_PROMPT}]
239
- for h in history[-8:]:
240
- if h[0]: messages.append({"role": "user", "content": h[0]})
241
- if h[1]: messages.append({"role": "assistant", "content": h[1]})
242
- messages.append({"role": "user", "content": user_input})
243
-
244
- prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
245
-
246
- # Run pipeline on the ZeroGPU helper
247
- response_text = generate_llm_response(prompt)
248
-
249
  try:
250
- data = extract_json(response_text)
251
- except Exception as e:
252
- print(f"JSON Parsing failed: {e}. Raw response: {response_text}")
253
- data = {
254
- "text": response_text.replace('"', "'"),
255
- "vrma": ANIMATIONS_LIST[0] if ANIMATIONS_LIST else "neutral2.vrma",
256
- "expressions": {"happy": 0.5},
257
- "rate": "+0%",
258
- "pitch": "+0Hz",
259
- "motion": "idle"
260
- }
261
-
262
- # Generate local TTS audio
263
- audio_path = await generate_speech(data.get("text", ""), data.get("rate", "+0%"), data.get("pitch", "+0Hz"))
264
-
265
- # Update history state
266
- history.append((user_input, data.get("text", "")))
267
-
268
- return "", history, audio_path, data
269
-
270
- # UI CSS Stylesheet
271
- CSS = """
272
- body { margin: 0; padding: 0; background-color: #ffe082 !important; overflow: hidden !important; font-family: sans-serif; }
273
- .gradio-container { max-width: 100% !important; border: none !important; padding: 0 !important; background: transparent !important; }
274
-
275
- /* Position Gradio overlay input bar on top of the fullscreen viewport */
276
- #ui-overlay {
277
- position: fixed !important;
278
- bottom: calc(15px + env(safe-area-inset-bottom, 0px)) !important;
279
- left: 50% !important;
280
- transform: translateX(-50%) !important;
281
- width: 90% !important;
282
- max-width: 600px !important;
283
- z-index: 10000 !important;
284
- display: block !important;
285
- visibility: visible !important;
286
- }
287
- #input-row, .input-row {
288
- display: flex !important;
289
- align-items: center !important;
290
- gap: 8px !important;
291
- background: rgba(15, 18, 32, 0.92) !important;
292
- padding: 10px 16px !important;
293
- border-radius: 40px !important;
294
- backdrop-filter: blur(15px) !important;
295
- border: 1px solid rgba(108, 204, 255, 0.3) !important;
296
- box-shadow: 0 8px 32px rgba(0,0,0,0.4) !important;
297
- visibility: visible !important;
298
- }
299
- #input-row > div, .input-row > div { flex: 1 !important; background: transparent !important; border: none !important; }
300
-
301
- /* Gradio text input adjustments */
302
- #ti textarea, #ti input {
303
- background: transparent !important;
304
- color: #fff !important;
305
- border: none !important;
306
- outline: none !important;
307
- box-shadow: none !important;
308
- font-size: 14px !important;
309
- }
310
-
311
- /* Rounded send button */
312
- #sb {
313
- border-radius: 50% !important;
314
- width: 48px !important;
315
- height: 48px !important;
316
- min-width: 48px !important;
317
- padding: 0 !important;
318
- background: linear-gradient(135deg, #6cf, #3ae) !important;
319
- color: #001220 !important;
320
- border: none !important;
321
- font-size: 20px !important;
322
- cursor: pointer !important;
323
- box-shadow: 0 4px 12px rgba(108, 204, 255, 0.4) !important;
324
- transition: transform 0.1s !important;
325
- z-index: 10001 !important;
326
- }
327
- #sb:active { transform: scale(0.9) !important; }
328
-
329
- /* Hide default Gradio layout elements */
330
- footer { display: none !important; }
331
- #component-1, #component-2 { background: transparent !important; }
332
- .gr-button-secondary { display: none !important; }
333
- """
334
-
335
- # Client-side routing event hook to listen for microphone transfers from the iframe
336
- HTML_HEAD = """
337
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
338
- <script>
339
- window.addEventListener('message', (e) => {
340
- if (e.data && e.data.type === 'mic_transcript') {
341
- const textInput = document.querySelector('#ti textarea') || document.querySelector('#ti input');
342
- if (textInput) {
343
- textInput.value = e.data.text;
344
- textInput.dispatchEvent(new Event('input', { bubbles: true }));
345
- setTimeout(() => {
346
- const sendBtn = document.getElementById('sb');
347
- if (sendBtn) sendBtn.click();
348
- }, 200);
349
- }
350
- }
351
- });
352
- </script>
353
- """
354
-
355
- # Construct URL parameters for the iframe to dynamically pass file and animation structures
356
- vrm_abs_path = os.path.abspath(VRM_MODEL_PATH)
357
- animation_abs_dir = os.path.abspath("animation")
358
- params = urllib.parse.urlencode({
359
- "animations": json.dumps(ANIMATIONS_LIST),
360
- "vrm_path": vrm_abs_path,
361
- "animation_dir": animation_abs_dir
362
- })
363
-
364
- iframe_html = f"""
365
- <iframe id="viewer-iframe" src="/file=viewer.html?{params}" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; border: none; z-index: 1;"></iframe>
366
- """
367
-
368
- # In Gradio 6.0+, css and head must be passed to demo.launch() instead of gr.Blocks()
369
- with gr.Blocks() as demo:
370
- history = gr.State([])
371
-
372
- # Register workspace root as directly served static paths in Gradio
373
- gr.set_static_paths(paths=[os.path.abspath(".")])
374
-
375
- # Render persistent, isolated 3D view iframe
376
- gr.HTML(iframe_html)
377
-
378
- with gr.Group(elem_id="ui-overlay"):
379
- with gr.Row(elem_id="input-row"):
380
- text_input = gr.Textbox(
381
- show_label=False,
382
- placeholder="Talk to me, darling...",
383
- container=False,
384
- scale=20,
385
- elem_id="ti"
386
- )
387
- send_btn = gr.Button("→", elem_id="sb")
388
-
389
- audio_out = gr.Audio(visible=False)
390
- data_out = gr.JSON(visible=False)
391
-
392
- def on_submit(text, hist):
393
- return chat_func(text, hist)
394
-
395
- # JS code to notify iframe that LLM generation has started
396
- submit_js = """
397
- () => {
398
- const iframe = document.getElementById('viewer-iframe');
399
- if (iframe && iframe.contentWindow) {
400
- iframe.contentWindow.postMessage({
401
- type: 'set_loading',
402
- val: true
403
- }, '*');
404
- }
405
- }
406
- """
407
-
408
- # JS code to notify iframe about completed generation, playing voice, and executing expression values
409
- update_js = """
410
- (text, hist, audio_path, data) => {
411
- const iframe = document.getElementById('viewer-iframe');
412
- if (iframe && iframe.contentWindow) {
413
- iframe.contentWindow.postMessage({
414
- type: 'set_loading',
415
- val: false
416
- }, '*');
417
- }
418
-
419
- let audio_url = "";
420
- if (audio_path) {
421
- if (typeof audio_path === 'string') {
422
- audio_url = audio_path;
423
- } else if (audio_path.url) {
424
- audio_url = audio_path.url;
425
- } else if (audio_path.path) {
426
- audio_url = "/gradio_api/file=" + audio_path.path;
427
- }
428
- }
429
- if (audio_url && data) {
430
- if (iframe && iframe.contentWindow) {
431
- iframe.contentWindow.postMessage({
432
- type: 'vrm_update',
433
- audio_url: audio_url,
434
- data: data
435
- }, '*');
436
- }
437
- }
438
- return ["", hist, audio_path, data];
439
  }
440
- """
441
-
442
- # Chain loaders dynamically: activate overlay instantly on client submit, then run server callbacks
443
- text_input.submit(
444
- None, None, None, js=submit_js
445
- ).then(
446
- on_submit, [text_input, history], [text_input, history, audio_out, data_out]
447
- ).then(
448
- None, [text_input, history, audio_out, data_out], js=update_js
449
- )
450
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  send_btn.click(
452
- None, None, None, js=submit_js
453
- ).then(
454
- on_submit, [text_input, history], [text_input, history, audio_out, data_out]
455
- ).then(
456
- None, [text_input, history, audio_out, data_out], js=update_js
 
 
 
 
 
 
 
 
457
  )
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
  if __name__ == "__main__":
460
- # Pass CSS and head parameters to launch() for Gradio 6.0+ compatibility
461
- demo.launch(
462
- server_name="0.0.0.0",
463
- server_port=7860,
464
- css=CSS,
465
- head=HTML_HEAD,
466
- allowed_paths=["."]
467
  )
 
1
+
2
  import os
3
+ import re
4
  import json
 
 
 
 
 
 
 
 
5
  import time
6
+ from functools import lru_cache
7
+ from typing import List, Dict, Any, Tuple
8
+
9
+ import gradio as gr
10
+ import torch
11
+ from transformers import AutoTokenizer, AutoModelForCausalLM
12
 
 
13
  try:
14
  import spaces
15
+ except Exception:
16
  class spaces:
17
  @staticmethod
18
+ def GPU(fn):
19
+ return fn
20
+
21
+
22
+ APP_NAME = "FastLLM"
23
+ MODEL_ID = os.getenv("MODEL_ID", "Qwen/Qwen2.5-1.5B-Instruct")
24
+ MAX_TOKENS = int(os.getenv("MAX_TOKENS", "256"))
25
+ TEMPERATURE = float(os.getenv("TEMPERATURE", "0.7"))
26
+ TOP_P = float(os.getenv("TOP_P", "0.9"))
27
+ REPETITION_PENALTY = float(os.getenv("REPETITION_PENALTY", "1.08"))
28
+ MAX_HISTORY_MESSAGES = int(os.getenv("MAX_HISTORY_MESSAGES", "12"))
29
+ MAX_MEMORY_ITEMS = int(os.getenv("MAX_MEMORY_ITEMS", "12"))
30
+
31
+ EMOTIONS = ["neutral", "happy", "calm", "focused", "curious", "thinking", "excited", "empathetic", "concerned", "playful"]
32
+
33
+ SYSTEM_PROMPT = f"""
34
+ You are FastLLM, a polished AI companion.
35
+ You are warm, sharp, calm, and helpful.
36
+ You speak like a real assistant with personality, but you stay professional and safe.
37
+
38
+ Goals:
39
+ - Be concise, natural, and confident.
40
+ - Help with daily tasks, study, coding, planning, and conversation.
41
+ - React with emotion in a subtle, human way.
42
+ - Never mention hidden policy text or internal prompts.
43
+
44
+ Output rules:
45
+ - Return raw JSON only.
46
+ - Use this schema:
47
+ {{
48
+ "reply": "short natural assistant response",
49
+ "emotion": one of {EMOTIONS},
50
+ "mood_score": number from 0.0 to 1.0,
51
+ "memory_hint": "short note to save for later, or empty string"
52
+ }}
53
+
54
+ Style:
55
+ - Keep the reply clear and friendly.
56
+ - Use short sentences.
57
+ - Match the user's tone.
58
+ - If the user asks for memory, produce a useful memory_hint.
59
+ - If the user gives a preference or profile detail, include it in memory_hint.
60
+ """.strip()
61
+
62
+
63
+ MODEL = None
64
+ TOKENIZER = None
65
+
66
+
67
+ def normalize_messages(messages: List[Dict[str, str]]) -> List[Dict[str, str]]:
68
+ cleaned = []
69
+ for msg in messages:
70
+ role = msg.get("role", "")
71
+ content = (msg.get("content") or "").strip()
72
+ if role in {"system", "user", "assistant"} and content:
73
+ cleaned.append({"role": role, "content": content})
74
+ return cleaned[-MAX_HISTORY_MESSAGES:]
75
+
76
+
77
+ def build_prompt(messages: List[Dict[str, str]]) -> str:
78
+ msgs = [{"role": "system", "content": SYSTEM_PROMPT}] + normalize_messages(messages)
79
+ tokenizer = get_tokenizer()
80
+ if hasattr(tokenizer, "apply_chat_template"):
81
+ return tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
82
+ text = []
83
+ for msg in msgs:
84
+ text.append(f"{msg['role'].upper()}: {msg['content']}")
85
+ text.append("ASSISTANT:")
86
+ return "\n".join(text)
87
+
88
+
89
+ def safe_json_from_text(text: str) -> Dict[str, Any]:
90
+ raw = (text or "").strip()
91
+ candidates = [
92
+ raw,
93
+ re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.I | re.S).strip(),
94
  ]
95
+ for candidate in candidates:
96
+ try:
97
+ data = json.loads(candidate)
98
+ if isinstance(data, dict):
99
+ return data
100
+ except Exception:
101
+ pass
102
 
103
+ start = raw.find("{")
104
+ end = raw.rfind("}")
105
+ if start != -1 and end != -1 and end > start:
106
+ chunk = raw[start : end + 1]
107
+ try:
108
+ data = json.loads(chunk)
109
+ if isinstance(data, dict):
110
+ return data
111
+ except Exception:
112
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ return {
115
+ "reply": raw if raw else "I’m here.",
116
+ "emotion": "neutral",
117
+ "mood_score": 0.5,
118
+ "memory_hint": "",
119
+ }
120
 
121
+
122
+ def clamp(v: float, lo: float = 0.0, hi: float = 1.0) -> float:
123
+ return max(lo, min(hi, v))
124
+
125
+
126
+ def get_tokenizer():
127
+ global TOKENIZER
128
+ if TOKENIZER is None:
129
+ TOKENIZER = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
130
+ return TOKENIZER
131
+
132
+
133
+ def load_model_once():
134
+ global MODEL, TOKENIZER
135
+ if MODEL is not None and TOKENIZER is not None:
136
+ return MODEL, TOKENIZER
137
+
138
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
139
+
140
+ load_kwargs = dict(low_cpu_mem_usage=True)
141
  try:
142
+ load_kwargs["dtype"] = torch.float16
143
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID, **load_kwargs)
144
+ except TypeError:
145
+ load_kwargs.pop("dtype", None)
146
+ load_kwargs["torch_dtype"] = torch.float16
147
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID, **load_kwargs)
148
+
149
+ if torch.cuda.is_available():
150
+ model = model.to("cuda")
151
+
152
+ model.eval()
153
+ if tokenizer.pad_token_id is None and tokenizer.eos_token_id is not None:
154
+ tokenizer.pad_token_id = tokenizer.eos_token_id
155
+
156
+ MODEL = model
157
+ TOKENIZER = tokenizer
158
+ return MODEL, TOKENIZER
159
+
160
+
161
  @spaces.GPU
162
+ def generate_reply(messages: List[Dict[str, str]]) -> Dict[str, Any]:
163
+ model, tokenizer = load_model_once()
164
+ prompt = build_prompt(messages)
165
+ inputs = tokenizer(prompt, return_tensors="pt")
166
+
167
+ device = "cuda" if torch.cuda.is_available() else "cpu"
168
+ inputs = {k: v.to(device) for k, v in inputs.items()}
169
+
 
 
 
 
170
  with torch.no_grad():
171
+ output = model.generate(
172
+ **inputs,
173
+ max_new_tokens=MAX_TOKENS,
174
+ do_sample=True,
175
+ temperature=TEMPERATURE,
176
+ top_p=TOP_P,
177
+ repetition_penalty=REPETITION_PENALTY,
178
+ pad_token_id=tokenizer.pad_token_id,
179
+ eos_token_id=tokenizer.eos_token_id,
180
  )
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
+ generated = tokenizer.decode(output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
183
+ data = safe_json_from_text(generated)
 
 
 
 
 
184
 
185
+ reply = str(data.get("reply", "")).strip()
186
+ if not reply:
187
+ reply = "I’m here."
 
 
 
 
 
188
 
189
+ emotion = str(data.get("emotion", "neutral")).strip().lower()
190
+ if emotion not in EMOTIONS:
191
+ emotion = "neutral"
192
+
193
+ mood_score = data.get("mood_score", 0.5)
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  try:
195
+ mood_score = clamp(float(mood_score))
196
+ except Exception:
197
+ mood_score = 0.5
198
+
199
+ memory_hint = str(data.get("memory_hint", "")).strip()
200
+
201
+ return {
202
+ "reply": reply,
203
+ "emotion": emotion,
204
+ "mood_score": mood_score,
205
+ "memory_hint": memory_hint,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  }
207
+
208
+
209
+ def extract_memory_candidate(user_text: str, assistant_text: str, memory_hint: str) -> str:
210
+ text = " ".join([user_text or "", assistant_text or "", memory_hint or ""]).strip()
211
+ if not text:
212
+ return ""
213
+ patterns = [
214
+ r"\bmy name is ([^.!,?\n]+)",
215
+ r"\bcall me ([^.!,?\n]+)",
216
+ r"\bi work as ([^.!,?\n]+)",
217
+ r"\bi like ([^.!,?\n]+)",
218
+ r"\bi prefer ([^.!,?\n]+)",
219
+ r"\bremember that ([^.!,?\n]+)",
220
+ ]
221
+ for pat in patterns:
222
+ m = re.search(pat, text, flags=re.I)
223
+ if m:
224
+ return m.group(1).strip()[:120]
225
+ if memory_hint:
226
+ return memory_hint[:120]
227
+ return ""
228
+
229
+
230
+ def render_status(emotion: str, mood_score: float, memory_count: int) -> str:
231
+ bars = "■" * max(1, int(round(mood_score * 10)))
232
+ bars = bars.ljust(10, "□")
233
+ return f"**Mood:** `{emotion}` | **Energy:** `{bars}` | **Memory items:** `{memory_count}`"
234
+
235
+
236
+ def add_turn(user_text: str, response: Dict[str, Any], chat: List[Dict[str, str]], memory: List[str]) -> Tuple[List[Dict[str, str]], List[str], str]:
237
+ chat.append({"role": "user", "content": user_text})
238
+ chat.append({"role": "assistant", "content": response["reply"]})
239
+
240
+ mem = extract_memory_candidate(user_text, response["reply"], response.get("memory_hint", ""))
241
+ if mem:
242
+ if mem not in memory:
243
+ memory = (memory + [mem])[-MAX_MEMORY_ITEMS:]
244
+
245
+ status = render_status(response["emotion"], response["mood_score"], len(memory))
246
+ return chat, memory, status
247
+
248
+
249
+ def clear_session():
250
+ return [], [], [], "Ready.", ""
251
+
252
+
253
+ def seed_examples():
254
+ return [
255
+ ["Help me plan my day.", None],
256
+ ["Remember that I build apps with Hugging Face and Python.", None],
257
+ ]
258
+
259
+
260
+ with gr.Blocks(theme=gr.themes.Soft(), css="""
261
+ #app-wrap { max-width: 1200px; margin: 0 auto; }
262
+ #header-card { border-radius: 24px; }
263
+ #chatbox { min-height: 560px; }
264
+ #memory-box { min-height: 220px; }
265
+ """) as demo:
266
+ chat_state = gr.State([])
267
+ memory_state = gr.State([])
268
+
269
+ with gr.Column(elem_id="app-wrap"):
270
+ with gr.Row():
271
+ with gr.Column(scale=3):
272
+ gr.Markdown(
273
+ f"# {APP_NAME}\nA local GPU companion built with Gradio and Qwen."
274
+ )
275
+ status_md = gr.Markdown("Ready.")
276
+ with gr.Column(scale=1):
277
+ clear_btn = gr.Button("Clear session", variant="secondary")
278
+
279
+ with gr.Row():
280
+ with gr.Column(scale=3):
281
+ chatbot = gr.Chatbot(
282
+ value=[],
283
+ type="messages",
284
+ height=560,
285
+ elem_id="chatbox",
286
+ show_copy_button=True,
287
+ )
288
+ with gr.Row():
289
+ user_text = gr.Textbox(
290
+ placeholder="Message FastLLM...",
291
+ scale=6,
292
+ show_label=False,
293
+ )
294
+ send_btn = gr.Button("Send", variant="primary", scale=1)
295
+
296
+ with gr.Accordion("Voice input", open=False):
297
+ audio_in = gr.Audio(
298
+ sources=["microphone", "upload"],
299
+ type="filepath",
300
+ label="Audio input",
301
+ )
302
+ transcribe_btn = gr.Button("Transcribe with local GPU model", variant="secondary")
303
+ transcript_box = gr.Textbox(label="Transcript", lines=3)
304
+
305
+ with gr.Column(scale=1):
306
+ emotion_box = gr.Textbox(label="Emotion", value="neutral", interactive=False)
307
+ mood_box = gr.Slider(label="Mood score", minimum=0, maximum=1, value=0.5, step=0.01, interactive=False)
308
+ memory_box = gr.Textbox(label="Session memory", lines=12, elem_id="memory-box")
309
+
310
+ def respond(user_message, chat, memory):
311
+ user_message = (user_message or "").strip()
312
+ if not user_message:
313
+ return "", chat, memory, chat, memory, "Ready.", "neutral", 0.5, ""
314
+
315
+ current_messages = chat + [{"role": "user", "content": user_message}]
316
+ result = generate_reply(current_messages)
317
+ chat, memory, status = add_turn(user_message, result, chat, memory)
318
+
319
+ memory_text = "\n".join(f"- {m}" for m in memory) if memory else "No saved memory yet."
320
+ return (
321
+ "",
322
+ chat,
323
+ memory,
324
+ chat,
325
+ memory_text,
326
+ status,
327
+ result["emotion"],
328
+ result["mood_score"],
329
+ result["reply"],
330
+ )
331
+
332
+ def transcribe(audio_path):
333
+ if not audio_path:
334
+ return ""
335
+ # Stub kept local and simple. Add a Whisper GPU pipeline here when you want audio-to-text.
336
+ return "Audio input connected. Add Whisper transcription in this slot."
337
+
338
  send_btn.click(
339
+ respond,
340
+ inputs=[user_text, chat_state, memory_state],
341
+ outputs=[user_text, chat_state, memory_state, chatbot, memory_box, status_md, emotion_box, mood_box, transcript_box],
342
+ )
343
+ user_text.submit(
344
+ respond,
345
+ inputs=[user_text, chat_state, memory_state],
346
+ outputs=[user_text, chat_state, memory_state, chatbot, memory_box, status_md, emotion_box, mood_box, transcript_box],
347
+ )
348
+ clear_btn.click(
349
+ clear_session,
350
+ inputs=[],
351
+ outputs=[chat_state, memory_state, chatbot, status_md, memory_box],
352
  )
353
+ transcribe_btn.click(
354
+ transcribe,
355
+ inputs=[audio_in],
356
+ outputs=[transcript_box],
357
+ )
358
+
359
+ demo.load(
360
+ lambda: ([], [], "Ready.", "neutral", 0.5, "No saved memory yet."),
361
+ inputs=[],
362
+ outputs=[chat_state, memory_state, status_md, emotion_box, mood_box, memory_box],
363
+ )
364
+
365
 
366
  if __name__ == "__main__":
367
+ demo.queue(default_concurrency_limit=1).launch(
368
+ server_name="0.0.0.0",
369
+ server_port=7860,
370
+ show_error=True,
 
 
 
371
  )