soundstarrain commited on
Commit
dd78bf6
·
0 Parent(s):
Files changed (4) hide show
  1. README.md +32 -0
  2. app.py +510 -0
  3. mock_server.py +79 -0
  4. requirements.txt +8 -0
README.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Murasaki LLM 在线演示
3
+ emoji: 🌸
4
+ colorFrom: purple
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: "4.44.0"
8
+ app_file: app.py
9
+ pinned: true
10
+ license: apache-2.0
11
+ ---
12
+
13
+ # Murasaki LLM 在线演示
14
+
15
+ **ACGN 日中翻译模型** - 专为轻小说、Galgame 剧本设计的高质量翻译引擎。
16
+
17
+ ## ✨ 特性
18
+
19
+ - 🎯 **深度思考 (CoT)**: 翻译前先分析文风、补全主语、梳理逻辑
20
+ - 📖 **双模式**: 轻小说文学风 / 剧本口语化
21
+ - ⚡ **双模型**: 8B 轻量快速 / 14B 高质量
22
+
23
+ ## 📊 限制
24
+
25
+ - 每日 30 次请求 / 20,000 字符
26
+ - 单次 100-5,000 字符
27
+ - 首次冷启动约 30 秒
28
+
29
+ ## 🔗 链接
30
+
31
+ - [GitHub](https://github.com/soundstarrain/Murasaki-Translator)
32
+ - [模型仓库](https://huggingface.co/Murasaki-Project)
app.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Murasaki 翻译模型在线演示
3
+ 布局: 两栏 + 底部可折叠思考面板
4
+ """
5
+
6
+ import os, re, json, requests, time
7
+ from datetime import date
8
+ from typing import Generator, Tuple
9
+ import gradio as gr
10
+
11
+ # ============================================================
12
+ # 配置
13
+ # ============================================================
14
+ MODAL_ENDPOINT_URL = os.getenv("MODAL_ENDPOINT_URL", "http://localhost:8000")
15
+ DEMO_SECRET_TOKEN = os.getenv("DEMO_SECRET_TOKEN", "")
16
+ UPSTASH_URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
17
+ UPSTASH_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
18
+
19
+ DAILY_CHAR_QUOTA = 20000
20
+ DAILY_REQ_QUOTA = 30
21
+ MIN_TEXT_LENGTH = 100
22
+ MAX_REQUEST_SIZE = 5000
23
+ MAX_CHUNK_SIZE = 1500
24
+ MIN_CHUNK_SIZE = 1000
25
+ REQUEST_TIMEOUT = 180
26
+
27
+ GITHUB_URL = "https://github.com/soundstarrain/Murasaki-Translator"
28
+ HF_REPO_URL = "https://huggingface.co/Murasaki-Project"
29
+
30
+ # ============================================================
31
+ # 辅助函数
32
+ # ============================================================
33
+ def estimate_tokens(text: str) -> int:
34
+ return max(1, int(len(text) / 1.5))
35
+
36
+ def post_process(text: str) -> str:
37
+ text = text.replace('"', '「').replace('"', '」').replace(''', '『').replace(''', '』')
38
+ lines = []
39
+ for line in text.splitlines():
40
+ if line.count('"') > 0 and line.count('"') % 2 == 0:
41
+ line = re.sub(r'"([^"]*)"', r'「\1」', line)
42
+ lines.append(line)
43
+ return "\n\n".join([l.rstrip() for l in "\n".join(lines).splitlines() if l.strip()])
44
+
45
+ def stream_parse(raw: str) -> Tuple[str, str]:
46
+ if '<think>' in raw:
47
+ parts = raw.split('<think>', 1)[1]
48
+ if '</think>' in parts:
49
+ t = parts.split('</think>', 1)
50
+ return t[0].strip(), t[1].lstrip()
51
+ return parts.strip(), ""
52
+ return "", raw.strip()
53
+
54
+ # ============================================================
55
+ # Redis
56
+ # ============================================================
57
+ def get_redis():
58
+ if not UPSTASH_URL or not UPSTASH_TOKEN: return None
59
+ try:
60
+ from upstash_redis import Redis
61
+ return Redis(url=UPSTASH_URL, token=UPSTASH_TOKEN)
62
+ except: return None
63
+
64
+ def check_quota(ip, chars):
65
+ r = get_redis()
66
+ if not r: return True, DAILY_CHAR_QUOTA, DAILY_REQ_QUOTA, ""
67
+ try:
68
+ ck, rk = f"demo:c:{ip}:{date.today()}", f"demo:r:{ip}:{date.today()}"
69
+ uc, ur = int(r.get(ck) or 0), int(r.get(rk) or 0)
70
+ rc, rr = DAILY_CHAR_QUOTA - uc, DAILY_REQ_QUOTA - ur
71
+ if rr <= 0: return False, rc, 0, "请求次数用尽"
72
+ if chars > rc: return False, rc, rr, "字符额度不足"
73
+ return True, rc, rr, ""
74
+ except: return True, 0, 0, ""
75
+
76
+ def deduct_quota(ip, chars):
77
+ r = get_redis()
78
+ if not r: return 0, 0
79
+ try:
80
+ ck, rk = f"demo:c:{ip}:{date.today()}", f"demo:r:{ip}:{date.today()}"
81
+ r.incrby(ck, chars); r.incr(rk); r.expire(ck, 86400); r.expire(rk, 86400)
82
+ return max(0, DAILY_CHAR_QUOTA - int(r.get(ck) or 0)), max(0, DAILY_REQ_QUOTA - int(r.get(rk) or 0))
83
+ except: return 0, 0
84
+
85
+ # ============================================================
86
+ # 翻译逻辑
87
+ # ============================================================
88
+ def split_chunks(text):
89
+ if len(text) <= MAX_CHUNK_SIZE: return [text]
90
+ paragraphs = re.split(r'\n\n+', text); chunks, cur = [], ""
91
+ for p in paragraphs:
92
+ if len(p) > MAX_CHUNK_SIZE:
93
+ if cur: chunks.append(cur.strip()); cur = ""
94
+ for s in re.split(r'(?<=[。!?\n])', p):
95
+ if len(cur) + len(s) <= MAX_CHUNK_SIZE: cur += s
96
+ else:
97
+ if cur: chunks.append(cur.strip())
98
+ cur = s
99
+ elif len(cur) + len(p) + 2 <= MAX_CHUNK_SIZE: cur += ("\n\n" if cur else "") + p
100
+ elif len(cur) >= MIN_CHUNK_SIZE: chunks.append(cur.strip()); cur = p
101
+ else: cur += ("\n\n" if cur else "") + p
102
+ if cur: chunks.append(cur.strip())
103
+ return chunks
104
+
105
+ def translate_stream(text, model, preset, ip):
106
+ text = text.strip()
107
+ if not text: yield "", "", "输入为空"; return
108
+ if len(text) < MIN_TEXT_LENGTH: yield "", "", f"最少 {MIN_TEXT_LENGTH} 字"; return
109
+ if len(text) > MAX_REQUEST_SIZE: yield "", "", f"最多 {MAX_REQUEST_SIZE} 字"; return
110
+ ok, rc, rr, err = check_quota(ip, len(text))
111
+ if not ok: yield "", "", f"限额已用尽: {err}"; return
112
+
113
+ start_time = time.time()
114
+ input_tokens = estimate_tokens(text)
115
+
116
+ chunks = split_chunks(text)
117
+ headers = {"Content-Type": "application/json"}
118
+ if DEMO_SECRET_TOKEN: headers["Authorization"] = f"Bearer {DEMO_SECRET_TOKEN}"
119
+ trans, think, total_output = "", "", ""
120
+
121
+ for i, chunk in enumerate(chunks):
122
+ try:
123
+ resp = requests.post(f"{MODAL_ENDPOINT_URL}/translate",
124
+ json={"text": chunk, "model": model, "preset": preset, "stream": True},
125
+ headers=headers, stream=True, timeout=REQUEST_TIMEOUT)
126
+ if resp.status_code != 200: yield trans, think, "服务错误"; return
127
+ raw = ""
128
+ for line in resp.iter_lines(decode_unicode=True):
129
+ if line and line.startswith("data: "):
130
+ d = line[6:]
131
+ if d == "[DONE]": break
132
+ data = json.loads(d)
133
+ if "error" in data:
134
+ yield trans, think, f"服务错误: {data['error']}"
135
+ return
136
+ raw += data.get("content", "")
137
+ ct, cr = stream_parse(raw)
138
+ elapsed = time.time() - start_time
139
+ status = f"处理中 {i+1}/{len(chunks)} · {elapsed:.1f}s"
140
+ yield trans + ("\n\n" if trans and cr else "") + cr, think + ("\n" if think and ct else "") + ct, status
141
+ t, th = stream_parse(raw)
142
+ trans += ("\n\n" if trans else "") + post_process(t)
143
+ total_output += raw
144
+ if th: think += ("\n\n" if think else "") + th
145
+ except Exception as e: yield trans, think, str(e); return
146
+
147
+ elapsed = time.time() - start_time
148
+ output_tokens = estimate_tokens(total_output)
149
+ speed = output_tokens / elapsed if elapsed > 0 else 0
150
+
151
+ rc, rr = deduct_quota(ip, len(text))
152
+ yield trans, think, f"完成 · 输入 {input_tokens} tokens · 输出 {output_tokens} tokens · {elapsed:.1f}s ({speed:.1f} t/s) · 剩余 {rc:,}字/{rr}次"
153
+
154
+ def wrapper(text, model, preset, req: gr.Request):
155
+ yield from translate_stream(text, model, preset, req.client.host if req else "x")
156
+
157
+ # ============================================================
158
+ # 自定义主题 - 白色背景 + 浅紫色关键文字
159
+ # ============================================================
160
+ theme = gr.themes.Soft(
161
+ primary_hue="violet",
162
+ secondary_hue="purple",
163
+ neutral_hue="slate",
164
+ ).set(
165
+ body_background_fill="#fafafa",
166
+ body_background_fill_dark="#fafafa",
167
+ block_background_fill="#ffffff",
168
+ block_background_fill_dark="#ffffff",
169
+ input_background_fill="#f8f9fa",
170
+ input_background_fill_dark="#f8f9fa",
171
+ button_primary_background_fill="#8b5cf6",
172
+ button_primary_background_fill_dark="#8b5cf6",
173
+ button_primary_background_fill_hover="#7c3aed",
174
+ button_primary_background_fill_hover_dark="#7c3aed",
175
+ body_text_color="#374151",
176
+ body_text_color_dark="#374151",
177
+ body_text_color_subdued="#6b7280",
178
+ body_text_color_subdued_dark="#6b7280",
179
+ block_label_background_fill="transparent",
180
+ block_label_background_fill_dark="transparent",
181
+ block_label_text_color="#8b5cf6",
182
+ block_label_text_color_dark="#8b5cf6",
183
+ block_title_text_color="#8b5cf6",
184
+ block_title_text_color_dark="#8b5cf6",
185
+ block_border_width="1px",
186
+ block_border_color="#e5e7eb",
187
+ block_border_color_dark="#e5e7eb",
188
+ input_border_width="1px",
189
+ input_border_color="#e5e7eb",
190
+ input_border_color_dark="#e5e7eb",
191
+ block_radius="12px",
192
+ input_radius="8px",
193
+ )
194
+
195
+ CSS = """
196
+ /* ============================================
197
+ 全局字体基准
198
+ ============================================ */
199
+ .gradio-container {
200
+ font-size: 16px !important;
201
+ }
202
+
203
+ /* 标题区 */
204
+ .app-header {
205
+ text-align: center;
206
+ padding: 20px 0 12px 0;
207
+ }
208
+ .app-header h1 {
209
+ font-size: 2rem;
210
+ font-weight: 700;
211
+ color: #8b5cf6;
212
+ margin: 0;
213
+ letter-spacing: -0.02em;
214
+ }
215
+ .app-header p {
216
+ color: #6b7280;
217
+ font-size: 1rem;
218
+ margin: 6px 0 0 0;
219
+ }
220
+
221
+ /* 说明框 */
222
+ .info-box {
223
+ background: #f3f0ff;
224
+ border: 1px solid #e9d5ff;
225
+ border-radius: 8px;
226
+ padding: 12px 16px;
227
+ margin-bottom: 12px;
228
+ }
229
+ .info-box h3 {
230
+ color: #7c3aed;
231
+ font-size: 0.9rem;
232
+ font-weight: 600;
233
+ margin: 0 0 6px 0;
234
+ }
235
+ .info-box p {
236
+ color: #4b5563;
237
+ font-size: 0.9rem;
238
+ line-height: 1.5;
239
+ margin: 3px 0;
240
+ }
241
+ .info-box a {
242
+ color: #8b5cf6;
243
+ text-decoration: none;
244
+ font-weight: 500;
245
+ }
246
+ .info-box a:hover {
247
+ text-decoration: underline;
248
+ }
249
+
250
+ /* 面板标签 */
251
+ .panel-label {
252
+ color: #8b5cf6;
253
+ font-size: 0.85rem;
254
+ font-weight: 600;
255
+ text-transform: uppercase;
256
+ letter-spacing: 0.08em;
257
+ margin-bottom: 6px;
258
+ }
259
+
260
+ /* ============================================
261
+ 全局边框重置 - 移除所有嵌套边框
262
+ ============================================ */
263
+
264
+ /* 所有 block 容器移除边框 */
265
+ .gradio-container .block {
266
+ border: none !important;
267
+ box-shadow: none !important;
268
+ background: transparent !important;
269
+ }
270
+
271
+ /* Form 容器移除边框 */
272
+ .gradio-container .form {
273
+ border: none !important;
274
+ box-shadow: none !important;
275
+ background: transparent !important;
276
+ }
277
+
278
+ /* 嵌套的 wrap 容器移除边框 */
279
+ .gradio-container .wrap {
280
+ border: none !important;
281
+ box-shadow: none !important;
282
+ }
283
+
284
+ /* ============================================
285
+ 文本框 - 只在 textarea 本身保留边框
286
+ ============================================ */
287
+ .gradio-textbox {
288
+ border: none !important;
289
+ box-shadow: none !important;
290
+ background: transparent !important;
291
+ }
292
+ .gradio-textbox > .wrap,
293
+ .gradio-textbox > div {
294
+ border: none !important;
295
+ box-shadow: none !important;
296
+ background: transparent !important;
297
+ }
298
+ textarea {
299
+ background: #f8f9fa !important;
300
+ border: 1px solid #e5e7eb !important;
301
+ border-radius: 8px !important;
302
+ color: #374151 !important;
303
+ font-size: 0.95rem !important;
304
+ line-height: 1.6 !important;
305
+ padding: 10px 12px !important;
306
+ }
307
+ textarea::placeholder {
308
+ color: #9ca3af !important;
309
+ font-size: 0.95rem !important;
310
+ }
311
+ textarea:focus {
312
+ border-color: #a78bfa !important;
313
+ box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1) !important;
314
+ }
315
+
316
+ /* ============================================
317
+ 下拉框 - 只保留外层单一边框
318
+ ============================================ */
319
+ .gradio-dropdown {
320
+ border: none !important;
321
+ box-shadow: none !important;
322
+ background: transparent !important;
323
+ }
324
+ .gradio-dropdown > .wrap,
325
+ .gradio-dropdown > div:not(ul) {
326
+ border: 1px solid #e5e7eb !important;
327
+ border-radius: 8px !important;
328
+ background: #ffffff !important;
329
+ box-shadow: none !important;
330
+ min-height: 38px !important;
331
+ }
332
+ .gradio-dropdown .wrap .wrap,
333
+ .gradio-dropdown .secondary-wrap,
334
+ .gradio-dropdown .border-none {
335
+ border: none !important;
336
+ box-shadow: none !important;
337
+ background: transparent !important;
338
+ }
339
+ .gradio-dropdown input,
340
+ .gradio-dropdown select {
341
+ border: none !important;
342
+ box-shadow: none !important;
343
+ background: transparent !important;
344
+ color: #374151 !important;
345
+ font-size: 0.95rem !important;
346
+ }
347
+ /* 下拉菜单列表 */
348
+ .gradio-dropdown ul[role="listbox"],
349
+ .gradio-container ul[role="listbox"] {
350
+ background: #ffffff !important;
351
+ border: 1px solid #e5e7eb !important;
352
+ border-radius: 8px !important;
353
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
354
+ }
355
+ .gradio-dropdown ul li,
356
+ .gradio-container ul[role="listbox"] li {
357
+ background: #ffffff !important;
358
+ color: #374151 !important;
359
+ }
360
+ .gradio-dropdown ul li:hover,
361
+ .gradio-container ul[role="listbox"] li:hover {
362
+ background: #f3f0ff !important;
363
+ }
364
+
365
+ /* ============================================
366
+ 折叠面板 - 简洁单边框
367
+ ============================================ */
368
+ .gradio-accordion {
369
+ border: 1px solid #e5e7eb !important;
370
+ border-radius: 8px !important;
371
+ background: #ffffff !important;
372
+ box-shadow: none !important;
373
+ overflow: hidden;
374
+ margin-top: 8px !important;
375
+ }
376
+ .gradio-accordion > div,
377
+ .gradio-accordion .label-wrap,
378
+ .gradio-accordion button {
379
+ border: none !important;
380
+ box-shadow: none !important;
381
+ background: #ffffff !important;
382
+ color: #374151 !important;
383
+ font-size: 0.95rem !important;
384
+ padding: 8px 12px !important;
385
+ }
386
+ .gradio-accordion button span {
387
+ color: #8b5cf6 !important;
388
+ font-size: 0.95rem !important;
389
+ font-weight: 600 !important;
390
+ }
391
+ .gradio-accordion .content {
392
+ border-top: 1px solid #e5e7eb !important;
393
+ padding: 8px 12px !important;
394
+ }
395
+
396
+ /* ============================================
397
+ 按钮
398
+ ============================================ */
399
+ button.primary {
400
+ margin-top: 16px !important;
401
+ font-weight: 600 !important;
402
+ font-size: 0.95rem !important;
403
+ padding: 8px 20px !important;
404
+ min-height: 40px !important;
405
+ box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25) !important;
406
+ border: none !important;
407
+ border-radius: 8px !important;
408
+ transition: all 0.15s ease !important;
409
+ }
410
+ button.primary:hover {
411
+ box-shadow: 0 3px 12px rgba(139, 92, 246, 0.35) !important;
412
+ transform: translateY(-1px) !important;
413
+ }
414
+
415
+ /* ============================================
416
+ 状态栏和禁用输入
417
+ ============================================ */
418
+ input:disabled, textarea:disabled {
419
+ background: #f8f9fa !important;
420
+ color: #6b7280 !important;
421
+ border: 1px solid #e5e7eb !important;
422
+ }
423
+
424
+ /* Row 容器 */
425
+ .gradio-row {
426
+ border: none !important;
427
+ box-shadow: none !important;
428
+ }
429
+
430
+ /* Column 容器 */
431
+ .gradio-column {
432
+ border: none !important;
433
+ box-shadow: none !important;
434
+ }
435
+
436
+ /* 标签文字 */
437
+ .gradio-container label,
438
+ .gradio-container .label-wrap span {
439
+ font-size: 0.95rem !important;
440
+ font-weight: 500 !important;
441
+ color: #8b5cf6 !important;
442
+ }
443
+
444
+ /* 状态栏 */
445
+ .gradio-container input[type="text"]:disabled {
446
+ font-size: 0.95rem !important;
447
+ color: #6b7280 !important;
448
+ }
449
+
450
+ /* 全局深色覆盖 */
451
+ .dark, [data-theme="dark"] {
452
+ --background-fill-primary: #fafafa !important;
453
+ --background-fill-secondary: #f8f9fa !important;
454
+ --block-background-fill: transparent !important;
455
+ --input-background-fill: #f8f9fa !important;
456
+ --body-text-color: #374151 !important;
457
+ }
458
+
459
+ footer { display: none !important; }
460
+ """
461
+
462
+ with gr.Blocks(title="Murasaki LLM 在线演示", theme=theme, css=CSS) as demo:
463
+ gr.HTML("""
464
+ <div class='app-header'>
465
+ <h1>Murasaki LLM 在线演示</h1>
466
+ <p>ACGN日中翻译模型</p>
467
+ </div>
468
+ """)
469
+
470
+ gr.HTML(f"""
471
+ <div class='info-box'>
472
+ <h3>使用说明</h3>
473
+ <p>1. 在线演示限额:每日 {DAILY_REQ_QUOTA} 次请求 / {DAILY_CHAR_QUOTA:,} 字符,单次 {MIN_TEXT_LENGTH}-{MAX_REQUEST_SIZE} 字符</p>
474
+ <p>2. 首次冷启动需要约 30 秒加载模型,请耐心等待</p>
475
+ <p>3. 推荐从 <a href='{HF_REPO_URL}'>模型仓库</a> 下载模型配合 <a href='{GITHUB_URL}'>Murasaki Translator</a> 本地运行</p>
476
+ </div>
477
+ """)
478
+
479
+ with gr.Row():
480
+ with gr.Column():
481
+ gr.HTML("<div class='panel-label'>原文</div>")
482
+ src = gr.Textbox(placeholder="粘贴日语原文...", lines=16, show_label=False)
483
+ with gr.Column():
484
+ gr.HTML("<div class='panel-label'>译文</div>")
485
+ res = gr.Textbox(placeholder="翻译结果将在此显示...", lines=16, show_label=False, interactive=False, show_copy_button=True)
486
+
487
+ with gr.Row():
488
+ m = gr.Dropdown(choices=["Murasaki-8B", "Murasaki-14B"], value="Murasaki-8B", label="模型", interactive=True, scale=2)
489
+ p = gr.Dropdown(choices=["轻小说", "剧本"], value="轻小说", label="预设", interactive=True, scale=2)
490
+ btn = gr.Button("翻译", variant="primary", scale=1)
491
+
492
+ thinking_accordion = gr.Accordion("AI 推理过程", open=False)
493
+ with thinking_accordion:
494
+ cot = gr.Textbox(placeholder="模型思考过程...", lines=5, show_label=False, interactive=False)
495
+
496
+ status = gr.Textbox(show_label=False, interactive=False, value="准备就绪", container=False)
497
+
498
+ def translate_wrapper(text, model, preset, req: gr.Request):
499
+ model_map = {"Murasaki-8B": "8b", "Murasaki-14B": "14b"}
500
+ preset_map = {"轻小说": "novel", "剧本": "script"}
501
+ for trans, think, st in translate_stream(text, model_map.get(model, "8b"), preset_map.get(preset, "novel"), req.client.host if req else "x"):
502
+ # 有思考内容时自动展开
503
+ should_open = bool(think and think.strip())
504
+ yield trans, think, st, gr.update(open=should_open)
505
+
506
+ btn.click(fn=translate_wrapper, inputs=[src, m, p], outputs=[res, cot, status, thinking_accordion])
507
+ src.submit(fn=translate_wrapper, inputs=[src, m, p], outputs=[res, cot, status, thinking_accordion])
508
+
509
+ if __name__ == "__main__":
510
+ demo.launch(server_name="0.0.0.0", server_port=7860)
mock_server.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 本地测试 Mock 服务器 (V2)
3
+
4
+ 用于模拟 Murasaki 模型带 <think> 标签的流式输出,配合三栏 UI 测试。
5
+
6
+ 使用方法:
7
+ python mock_server.py
8
+ """
9
+
10
+ import json
11
+ import asyncio
12
+ from fastapi import FastAPI
13
+ from fastapi.responses import StreamingResponse
14
+ from pydantic import BaseModel
15
+ from typing import Literal
16
+
17
+ app = FastAPI()
18
+
19
+ class TranslateRequest(BaseModel):
20
+ text: str
21
+ model: Literal["8b", "14b"] = "8b"
22
+ preset: Literal["novel", "script", "short"] = "novel"
23
+ stream: bool = False
24
+
25
+ MOCK_DATA = {
26
+ "novel": {
27
+ "think": "正在分析文风... 这是一个描写雨天的片段,语境忧郁。主语补全为『她』。采用轻小说优美文风。",
28
+ "trans": "她凝视着窗外。雨滴顺着明净的玻璃窗缓缓滑落,像是断了线的珍珠,在灰暗的天色中留下一道道模糊的痕迹。"
29
+ },
30
+ "script": {
31
+ "think": "对话场景分析。语气:平淡。角色:路人甲。采用简洁翻译。",
32
+ "trans": "「嗯,我知道了。」"
33
+ }
34
+ }
35
+
36
+ @app.post("/translate")
37
+ async def translate(request: TranslateRequest):
38
+ if request.stream:
39
+ async def stream_generator():
40
+ data = MOCK_DATA.get(request.preset, MOCK_DATA["novel"])
41
+
42
+ # 构造带标签的原始输出
43
+ full_text = f"<think>\n{data['think']}\n</think>\n{data['trans']}"
44
+
45
+ # 逐字输出模拟流式
46
+ for char in full_text:
47
+ yield f"data: {json.dumps({'content': char})}\n\n"
48
+ await asyncio.sleep(0.01) # 快一点
49
+
50
+ yield "data: [DONE]\n\n"
51
+
52
+ return StreamingResponse(stream_generator(), media_type="text/event-stream")
53
+
54
+ # 非流式
55
+ data = MOCK_DATA.get(request.preset, MOCK_DATA["novel"])
56
+ return {
57
+ "translation": data["trans"],
58
+ "thinking": data["think"],
59
+ "model_used": f"Murasaki {request.model.upper()} (Mock)"
60
+ }
61
+
62
+ @app.get("/health")
63
+ async def health(): return {"status": "ok"}
64
+
65
+ @app.get("/models")
66
+ async def list_models():
67
+ return {
68
+ "models": [
69
+ {"id": "8b", "display_name": "Murasaki 8B (Mock)"},
70
+ {"id": "14b", "display_name": "Murasaki 14B (Mock)"}
71
+ ],
72
+ "default": "8b"
73
+ }
74
+
75
+ if __name__ == "__main__":
76
+ import uvicorn
77
+ print("\n🚀 Mock 服务器 V2 启动中...")
78
+ print("📍 地址: http://localhost:8000")
79
+ uvicorn.run(app, host="0.0.0.0", port=8000)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # HF Space 依赖
2
+ gradio>=4.0.0
3
+ requests>=2.28.0
4
+ upstash-redis>=1.0.0
5
+
6
+ # 本地测试用(可选)
7
+ fastapi>=0.100.0
8
+ uvicorn>=0.20.0