forked from Sen-illion/DN
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgame_server.py
More file actions
1692 lines (1541 loc) · 89.4 KB
/
game_server.py
File metadata and controls
1692 lines (1541 loc) · 89.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
import os
import sys
import json
import hashlib
import threading
import time
import logging
import traceback
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from datetime import datetime
from dotenv import load_dotenv
from flask import Flask, request, jsonify, send_file, send_from_directory, Response, stream_with_context
# Windows 下强制 UTF-8 输出,避免控制台乱码导致“看不到日志”
if sys.platform == 'win32':
os.environ['PYTHONIOENCODING'] = 'utf-8'
os.environ.setdefault('PYTHONUTF8', '1')
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
from main2 import (
llm_generate_global,
_generate_single_option,
_generate_single_option_text_only,
generate_all_options,
modify_ending_content,
generate_ending_prediction,
generate_scene_image,
get_video_task_status,
generate_game_id,
generate_main_character_image,
)
from server.config import SAVE_DIR, IMAGE_CACHE_DIR, ensure_dirs
from server.cache import (
pregeneration_cache,
cache_lock,
cleanup_old_cache,
cleanup_used_options,
)
from server.provider_control import set_provider_priority
from server.utils import clean_error_message, generate_scene_id
from server.pregeneration import _pregenerate_next_layers_logic
from server.config import IMAGE_CACHE_DIR
from server.events import subscribe as sse_subscribe, unsubscribe as sse_unsubscribe, publish as sse_publish
from src.characters.supporting import (
get_or_create_supporting_role_archive,
archive_supporting_role_first_appearance,
retry_supporting_role_reference_crop,
)
from src.characters.archives import _load_role_archives
from src.utils.text_utils import _clip_text, get_protagonist_names
from src.image.prompt_optimize import normalize_image_style
# 初始化 Flask 应用
app = Flask(__name__)
load_dotenv()
ensure_dirs()
# 固定请求日志级别(debug=False 时也能看到访问日志)
# 同时把日志写入文件,避免控制台丢输出
_LOG_DIR = Path(__file__).resolve().parent / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_LOG_FILE = _LOG_DIR / "backend.log"
_root_logger = logging.getLogger()
_root_logger.setLevel(logging.INFO)
if not _root_logger.handlers:
_root_logger.addHandler(logging.StreamHandler(sys.stdout))
try:
from logging.handlers import RotatingFileHandler
_fh = RotatingFileHandler(str(_LOG_FILE), maxBytes=2_000_000, backupCount=3, encoding="utf-8")
_fh.setLevel(logging.INFO)
_root_logger.addHandler(_fh)
except Exception:
pass
logging.getLogger("werkzeug").setLevel(logging.INFO)
_SCENE_IMAGE_REQUESTS = {}
_SCENE_IMAGE_RECENT = {}
_SCENE_IMAGE_REQUESTS_LOCK = threading.Lock()
_SCENE_IMAGE_RECENT_TTL_SECONDS = float(os.getenv("SCENE_IMAGE_RECENT_TTL_SECONDS", "15"))
@app.before_request
def _log_request_in():
# 不依赖 werkzeug 的 access log,强制打印到 stdout,并 flush
try:
qs = request.query_string.decode("utf-8", "replace") if request.query_string else ""
except Exception:
qs = ""
path = request.path + (("?" + qs) if qs else "")
print(f"➡️ {request.method} {path}", flush=True)
@app.after_request
def _log_request_out(resp):
try:
print(f"⬅️ {request.method} {request.path} -> {resp.status_code}", flush=True)
except Exception:
pass
return resp
@app.errorhandler(Exception)
def _log_unhandled_exception(err):
# 任何未捕获异常都打印 traceback,避免“后端没日志”
try:
print("💥 Unhandled exception:", flush=True)
traceback.print_exc()
except Exception:
pass
return jsonify({"status": "error", "message": clean_error_message(str(err))}), 500
@app.route('/events', methods=['GET'])
def sse_events():
"""
SSE: 前端订阅后端推送事件(如“剧情图生成完成”)。
Query:
- sceneId: 当前展示所对应的 sceneId(建议必传)
- gameId: 可选,用于更精确路由(多局并行时)
"""
scene_id = request.args.get("sceneId", "") or ""
game_id = request.args.get("gameId", "") or ""
q = sse_subscribe(scene_id, game_id=game_id)
def gen():
# 初始 hello,避免某些代理缓冲
yield "event: hello\ndata: {}\n\n"
last_heartbeat = 0.0
try:
while True:
try:
msg = q.get(timeout=1.0)
yield msg
except Exception:
# heartbeat 防止连接被中间层断开
import time
now = time.time()
if now - last_heartbeat >= 15.0:
last_heartbeat = now
yield "event: ping\ndata: {}\n\n"
finally:
sse_unsubscribe(scene_id, q, game_id=game_id)
return Response(
stream_with_context(gen()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
def _archive_supporting_roles_on_option_shown(game_id, option_data, global_state, protagonist_names=None):
"""
仅当选项即将展示到前端时,为本段首次出场的配角做初登场图建档。
避免未选中选项中的角色也被建档。
若 display_name 与主角姓名/别名匹配,则跳过建档,避免将主角误建档为配角。
"""
if not game_id or not option_data or not isinstance(option_data, dict):
return
plot_supporting_characters = option_data.get("plot_supporting_characters") or []
if not plot_supporting_characters:
return
scene_image = option_data.get("scene_image") or {}
scene_url = (scene_image.get("url") or "").strip()
prompt = (scene_image.get("prompt") or "").strip()
if not scene_url or not prompt:
return
# 支持本地缓存路径与云端 URL
if scene_url.startswith("/image_cache/") or scene_url.startswith("image_cache/"):
name = Path(scene_url).name
local_path = Path(IMAGE_CACHE_DIR) / name
if not local_path.exists():
return
scene_path_for_archive = str(local_path)
elif scene_url.startswith("http://") or scene_url.startswith("https://"):
scene_path_for_archive = scene_url
else:
return
# 主角称呼集合:用于排除主角(如「拍短片的」可能是主角别称)
if protagonist_names is None and global_state:
protagonist_names = get_protagonist_names(global_state)
protagonist_names = protagonist_names or set()
if isinstance(protagonist_names, (list, tuple)):
protagonist_names = set(str(x).strip() for x in protagonist_names if x)
chars = (global_state or {}).get("supporting_role_archives") or {}
if not isinstance(chars, dict) or not chars:
chars = _load_role_archives(game_id)
first_appear_scene = _clip_text(option_data.get("scene", ""), 60)
for display_name, slot in plot_supporting_characters:
dn = str(display_name).strip()
if protagonist_names and dn in protagonist_names:
print(f"⏭️ 跳过建档:{dn} 为主角称呼,不建配角档案")
continue
role_info = chars.get(slot, {}) or chars.get(display_name, {}) or {}
if not isinstance(role_info, dict):
role_info = {}
arch = get_or_create_supporting_role_archive(
game_id, dn, str(slot).strip(), role_info, first_appear_scene
)
if arch.get("_pending_first_appearance"):
try:
archive_supporting_role_first_appearance(game_id, arch, scene_path_for_archive, prompt)
except Exception as e:
print(f"⚠️ 配角初登场建档失败:{e}")
elif not arch.get("reference_ready"):
# 旧档案/误建档场景:当本次展示图里真正出现该角色时自动补齐单人参考图
try:
retry_supporting_role_reference_crop(
game_id=game_id,
display_name=dn,
scene_image_path=str(local_path),
prompt=prompt,
first_appear_scene=first_appear_scene,
)
except Exception as e:
print(f"⚠️ 配角参考图补齐失败:{e}")
# 允许前端跨域访问
@app.after_request
def after_request(response):
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
return response
def _normalize_checkpoint_memory(raw_value):
if not isinstance(raw_value, list):
return []
normalized = []
for item in raw_value[-30:]:
if not isinstance(item, dict):
continue
normalized.append({
"id": item.get("id") or f"cp_{int(datetime.now().timestamp() * 1000)}",
"source": item.get("source", "unknown"),
"chapter": item.get("chapter", ""),
"recap": item.get("recap", ""),
"keywords": item.get("keywords") if isinstance(item.get("keywords"), list) else [],
"selectedOption": item.get("selectedOption", ""),
"timestamp": item.get("timestamp") or datetime.now().isoformat(),
})
return normalized
def _build_checkpoint_packet(option_data, global_state, selected_option):
flow = (global_state or {}).get("flow_worldline", {}) if isinstance(global_state, dict) else {}
flow_update = (option_data or {}).get("flow_update", {}) if isinstance(option_data, dict) else {}
chapter = flow.get("current_chapter", "chapter1")
quest_progress = flow_update.get("quest_progress") or flow.get("quest_progress") or "主线正在推进。"
scene = ((option_data or {}).get("scene") or "").strip()
recap_scene = scene if scene else "剧情继续推进中。"
recap_text = f"你选择了“{selected_option}”。当前章节:{chapter}。主线进展:{quest_progress}。最近剧情:{recap_scene}"
return {
"chapter": chapter,
"quest_delta": flow_update.get("quest_progress", ""),
"chapter_conflict_solved": bool(flow_update.get("chapter_conflict_solved", False)),
"recap_text": recap_text
}
def _cleanup_scene_image_recent(now_ts=None):
now_ts = now_ts or time.time()
expired = []
for key, item in _SCENE_IMAGE_RECENT.items():
if now_ts - item.get("ts", now_ts) > _SCENE_IMAGE_RECENT_TTL_SECONDS:
expired.append(key)
for key in expired:
_SCENE_IMAGE_RECENT.pop(key, None)
def _build_scene_image_request_key(scene_description, style, viewport_width, viewport_height, global_state):
visual_context = (global_state or {}).get("_visual_context", {}) if isinstance(global_state, dict) else {}
scene_id = visual_context.get("sceneId") or (global_state or {}).get("sceneId") or ""
game_id = (global_state or {}).get("game_id", "") if isinstance(global_state, dict) else ""
prompt_basis = json.dumps(
{
"scene_description": (scene_description or "").strip(),
"style": style or "default",
"viewport_width": viewport_width,
"viewport_height": viewport_height,
"scene_id": scene_id,
"game_id": game_id,
},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.md5(prompt_basis.encode("utf-8")).hexdigest()
# 核心接口:生成游戏世界观
@app.route('/generate-worldview', methods=['POST'])
def generate_worldview():
try:
# 获取前端传的参数
data = request.json
game_theme = data.get('gameTheme', '').strip()
protagonist_attr = data.get('protagonistAttr', {})
difficulty = data.get('difficulty', '中等')
tone_key = data.get('toneKey', 'normal_ending')
image_style = normalize_image_style(data.get('imageStyle', None)) # 图片风格选择
# 基础校验
if not game_theme:
return jsonify({"status": "error", "message": "游戏主题不能为空!"})
# 生成游戏ID
game_id = generate_game_id()
print(f"🎮 生成游戏ID: {game_id}")
# 调用后端生成世界观的函数
try:
global_state = llm_generate_global(game_theme, protagonist_attr, difficulty, tone_key)
# 保存游戏ID到global_state
global_state['game_id'] = game_id
# 🔑 保存用户输入的主题(用于现实题材/IP检索命中率)
# 注意:core_worldview.game_style 往往是较长的“风格描述”,不一定等同于用户输入主题名。
global_state['user_theme'] = game_theme
# 保存图片风格到global_state
if image_style:
global_state['image_style'] = image_style
print(f"✅ 图片风格已保存到global_state: {image_style}")
except ValueError as e:
# 如果是API配置错误,返回明确的错误信息
error_msg = str(e)
if "缺少必要的API配置" in error_msg or "API" in error_msg:
return jsonify({
"status": "error",
"message": f"AI生成功能未配置:{error_msg}\n\n请检查.env文件,确保配置了以下环境变量:\n- Camera_Analyst_API_KEY\n- Camera_Analyst_BASE_URL\n- Camera_Analyst_MODEL"
})
raise # 其他ValueError继续抛出
# ✅ 世界观生成完成后:立刻启动主角形象生成(后台线程,不阻塞响应)
# 目的:用户正在查看世界观时并行生图;并将完整世界观文本/结构传入提示词LLM。
try:
import copy
def generate_main_character_after_worldview_async(gs_snapshot, game_id_arg):
"""世界观生成完成后触发:主角形象生成(后台线程)。game_id_arg 必须传入,避免闭包读到后续请求覆盖的值。"""
# ???????????????????????????????????
set_provider_priority("high")
try:
print(f"🎨 开始生成主角形象(游戏ID: {game_id_arg},世界观已就绪,后台并行)...")
result = generate_main_character_image(
protagonist_attr=protagonist_attr,
global_state=gs_snapshot,
image_style=image_style,
game_id=game_id_arg
)
if result:
print(f"✅ 主角形象生成完成(游戏ID: {game_id_arg})")
else:
print(f"⚠️ 主角形象生成失败(游戏ID: {game_id_arg}),但游戏可以继续")
except Exception as e:
print(f"❌ 主角形象生成出错(游戏ID: {game_id_arg}):{str(e)}")
import traceback
traceback.print_exc()
gs_snapshot = copy.deepcopy(global_state) if isinstance(global_state, dict) else global_state
threading.Thread(
target=generate_main_character_after_worldview_async,
args=(gs_snapshot, game_id),
daemon=True
).start()
print("✅ 主角形象生成任务已启动(世界观生成完成后触发,后台并行)")
except Exception as e:
print(f"⚠️ 启动主角形象生成任务失败:{str(e)}")
# 世界观生成完成后,更新主角形象信息到global_state(如果已生成)
try:
# 检查主角形象是否已生成
main_character_path = f"initial/main_character/{game_id}/main_character.png"
if os.path.exists(main_character_path):
global_state['main_character'] = {
'game_id': game_id,
'image_url': f"/initial/main_character/{game_id}/main_character.png",
'image_path': main_character_path,
'width': 1024,
'height': 1536
}
print(f"✅ 主角形象信息已更新到global_state")
except Exception as e:
print(f"⚠️ 更新主角形象信息失败:{str(e)}")
# 世界观生成成功后,立即启动第一次选项的生成(后台线程,不使用预生成机制)
def generate_initial_options():
"""生成第一次选项(根据世界观动态生成)"""
try:
print(f"🔄 开始生成第一次选项(根据世界观动态生成)...")
# 根据世界观生成初始场景和选项
# 使用"开始游戏"作为初始选项,生成第一个场景和后续选项
initial_option = "开始游戏"
result = _generate_single_option(0, initial_option, global_state)
if isinstance(result, dict):
initial_option_data = result.get('data', result)
else:
initial_option_data = result
# 获取生成的初始选项列表
initial_options = initial_option_data.get('next_options', [])
if not initial_options:
# 如果生成失败,使用默认选项
initial_options = ["继续深入探索", "查看周围环境"]
# 限制选项数量为2个
if len(initial_options) > 2:
initial_options = initial_options[:2]
# ✅ 性能优化:第一次只生成“当前轮(初始场景)的文本+画面+下一步选项”,不再在这里预生成每个选项的剧情/图片。
# 后续预生成仍由前端触发 /pregenerate-next-layers(用户阅读时间后台生成),逻辑保持一致。
# 存储到特殊缓存位置(仅初始场景,不预生成选项剧情)
with cache_lock:
if 'initial' not in pregeneration_cache:
pregeneration_cache['initial'] = {
'generation_events': {}
}
initial_cache = pregeneration_cache['initial']
# 不再填充 layer1(每个选项的剧情),交给后续预生成或按需生成
initial_cache['layer1'] = {}
# 确保initial_scene不为空,如果为空则使用默认场景
initial_scene = initial_option_data.get('scene', '')
if not initial_scene or initial_scene.strip() == '':
print(f"⚠️ 初始场景为空,使用默认场景")
initial_scene = "你开始了你的冒险之旅."
# 修复:提取并保存初始场景的图片数据(含 scene_text_hash,避免 /generate-option 误判文本变化而重复生成)
initial_scene_image = initial_option_data.get('scene_image', None)
if initial_scene_image:
if not initial_scene_image.get("image_type"):
initial_scene_image = dict(initial_scene_image)
initial_scene_image["image_type"] = "story_scene"
if not initial_scene_image.get('scene_text_hash') and initial_scene and initial_scene.strip():
initial_scene_image = dict(initial_scene_image)
initial_scene_image['scene_text_hash'] = hashlib.md5(initial_scene.encode('utf-8')).hexdigest()
print(f"✅ 初始场景图片数据已提取: {initial_scene_image.get('url', 'N/A')[:80]}...")
else:
print(f"⚠️ 初始场景没有图片数据(因使用默认剧情,AI 未返回【场景】格式)")
# 默认剧情时补生成一张场景图,保证首次进入也有图
if initial_scene and initial_scene.strip():
try:
img = generate_scene_image(initial_scene, global_state, "default", use_cache=True)
if img and img.get("url"):
initial_scene_image = dict(img)
initial_scene_image["image_type"] = "story_scene"
initial_scene_image["scene_text_hash"] = hashlib.md5(initial_scene.encode("utf-8")).hexdigest()
print(f"✅ 已为默认剧情补生成初始场景图: {initial_scene_image.get('url', '')[:80]}...")
except Exception as img_e:
print(f"⚠️ 默认剧情补生成场景图失败,继续无图: {img_e}")
initial_cache['initial_scene'] = initial_scene
initial_cache['initial_scene_image'] = initial_scene_image # 保存图片数据
initial_cache['initial_options'] = initial_options
# 保存本段出场配角,供展示初始场景时建档用(与后续选项一致)
initial_cache['plot_supporting_characters'] = initial_option_data.get('plot_supporting_characters', [])
# 选项剧情未预生成,状态保持 pending(如后续需要可由预生成写入 scene_id 对应缓存)
initial_cache['generation_status'] = {i: 'pending' for i in range(len(initial_options))}
initial_cache['completed'] = True
# 首屏预生成:生成 scene_id 并写入缓存,供前端与预生成共用
server_scene_id = generate_scene_id(str(global_state), str(initial_options))
initial_cache['pregeneration_scene_id'] = server_scene_id
# 触发等待事件(如果有线程在等待)
events = initial_cache.get('generation_events', {})
if 'main' in events:
events['main'].set()
# 首屏预生成:立即在后台启动首屏选项的预生成(与后续轮同一套逻辑)
_pregenerate_next_layers_logic(global_state, initial_options, server_scene_id)
print(f"✅ 第一次选项生成完成(含首屏预生成),选项数:{len(initial_options)},预生成 scene_id:{server_scene_id}")
except Exception as e:
print(f"❌ 生成第一次选项失败:{str(e)}")
import traceback
traceback.print_exc()
# 即使失败,也设置一个标记,避免前端无限等待
with cache_lock:
if 'initial' not in pregeneration_cache:
pregeneration_cache['initial'] = {
'generation_events': {}
}
initial_cache = pregeneration_cache['initial']
initial_cache['completed'] = False
initial_cache['error'] = str(e)
# 触发等待事件(避免前端无限等待)
events = initial_cache.get('generation_events', {})
if 'main' in events:
events['main'].set()
# 启动后台线程生成第一次选项(不阻塞响应)
thread = threading.Thread(target=generate_initial_options, daemon=True)
thread.start()
# 验证返回的数据结构
if not global_state:
return jsonify({
"status": "error",
"message": "世界观生成失败:返回的数据为空"
})
# 验证核心字段
if not global_state.get('core_worldview'):
return jsonify({
"status": "error",
"message": "世界观生成失败:缺少核心世界观数据"
})
print(f"✅ 世界观生成成功,返回数据包含:")
print(f" - core_worldview: {bool(global_state.get('core_worldview'))}")
print(f" - chapters: {bool(global_state.get('core_worldview', {}).get('chapters'))}")
print(f" - chapter1: {bool(global_state.get('core_worldview', {}).get('chapters', {}).get('chapter1'))}")
# 返回结果
return jsonify({
"status": "success",
"message": "世界观生成成功!",
"globalState": global_state
})
except Exception as e:
error_msg = clean_error_message(str(e))
return jsonify({"status": "error", "message": f"世界观生成失败:{error_msg}"})
# 核心接口:生成单个选项对应的剧情(支持智能等待,不降级为实时生成)
@app.route('/generate-option', methods=['POST'])
def generate_option():
try:
# 获取前端传的参数
data = request.json
option = data.get('option', '').strip()
global_state = data.get('globalState', {})
option_index = data.get('optionIndex', 0)
scene_id = data.get('sceneId', None) # 前端传入的场景ID,用于缓存查找
current_options = data.get('currentOptions', []) # 当前选项列表,用于触发优先生成
# 🔍 调试日志:显示前端传入的参数
print(f"🔍 [generate-option] 收到请求:")
print(f" - 选项内容:{option[:50]}...")
print(f" - 选项索引:{option_index}")
print(f" - 前端传入的 sceneId:{scene_id}")
print(f" - 当前缓存中的所有 scene_id:{list(pregeneration_cache.keys())}")
# 新增:图片依赖生成(视觉连续性上下文)
# - 同一场景统一风格/物件
# - 下一剧情图片参考上一剧情图片生成
previous_scene_image = data.get('previousSceneImage', None) # {url,prompt,...}(可选)
previous_scene_text = data.get('previousSceneText', '') # 可选:上一剧情文本(用于提示词连续性)
if isinstance(global_state, dict) and (previous_scene_image or previous_scene_text):
global_state['_visual_context'] = {
"sceneId": scene_id,
"previousSceneImage": previous_scene_image,
"previousSceneText": previous_scene_text
}
# 也写入缓存(便于后续在该 scene_id 下触发的优先生成/补生成复用)
if scene_id:
with cache_lock:
if scene_id in pregeneration_cache:
pregeneration_cache[scene_id]['visual_context'] = global_state['_visual_context']
# 基础校验
if not option:
return jsonify({"status": "error", "message": "选项内容不能为空!"})
if not global_state:
return jsonify({"status": "error", "message": "全局状态不能为空!"})
option_data = None
need_wait = False
wait_event = None # 初始化wait_event
layer2_thread_to_wait = None # 用于在释放锁后等待第二层线程
# 处理第一次生成的情况(sceneId为null或'initial')
if not scene_id or scene_id == 'initial':
# 第一次生成:从initial缓存读取
with cache_lock:
# 如果initial缓存不存在,创建并等待
if 'initial' not in pregeneration_cache:
pregeneration_cache['initial'] = {
'generation_events': {},
'completed': False
}
need_wait = True
else:
initial_cache = pregeneration_cache['initial']
# 检查是否生成完成
if initial_cache.get('completed', False):
# 如果用户选择的是"开始游戏"(option_index=0),返回初始场景
if option_index == 0 and option == "开始游戏":
# 返回初始场景和选项
initial_scene = initial_cache.get('initial_scene', '')
initial_scene_image = initial_cache.get('initial_scene_image', None) # 修复:读取图片数据
initial_options = initial_cache.get('initial_options', [])
# 确保initial_scene不为空
if not initial_scene or initial_scene.strip() == '':
print(f"⚠️ 从缓存读取的初始场景为空,使用默认场景")
initial_scene = "你开始了你的冒险之旅."
option_data = {
"scene": initial_scene,
"scene_image": initial_scene_image, # 修复:包含图片数据
"next_options": initial_options,
"flow_update": {},
"deep_background_links": {},
"plot_supporting_characters": initial_cache.get("plot_supporting_characters", []),
}
if initial_scene_image:
print(f"✅ 从initial缓存中读取初始场景和选项,场景长度: {len(initial_scene)},包含图片数据")
else:
print(f"✅ 从initial缓存中读取初始场景和选项,场景长度: {len(initial_scene)},无图片数据")
else:
# 从layer1中读取对应选项的数据
layer1_data = initial_cache.get('layer1', {})
if option_index in layer1_data:
option_data = layer1_data[option_index]
print(f"✅ 从initial缓存中读取选项 {option_index} 的剧情")
else:
# 如果找不到,等待生成完成
need_wait = True
else:
# 还未生成完成,等待
need_wait = True
# 如果需要等待,创建等待事件
if need_wait:
initial_cache = pregeneration_cache['initial']
events = initial_cache.setdefault('generation_events', {})
if 'main' not in events:
events['main'] = threading.Event()
wait_event = events['main']
if scene_id and scene_id != 'initial':
with cache_lock:
# 🔍 调试日志:检查 scene_id 是否在缓存中
print(f"🔍 [generate-option] 检查 scene_id 是否在缓存中...")
print(f" - 查找的 scene_id:{scene_id}")
print(f" - 缓存中的 scene_id 列表:{list(pregeneration_cache.keys())}")
print(f" - scene_id 是否在缓存中:{scene_id in pregeneration_cache}")
if scene_id in pregeneration_cache:
cache_entry = pregeneration_cache[scene_id]
print(f"✅ [generate-option] scene_id 匹配成功,找到缓存条目")
print(f" - 缓存条目中的 layer1 选项索引:{list(cache_entry.get('layer1', {}).keys())}")
print(f" - 缓存条目中的生成状态:{cache_entry.get('generation_status', {})}")
# 情况1:缓存中已有该选项的数据
if 'layer1' in cache_entry and option_index in cache_entry['layer1']:
option_data_temp = cache_entry['layer1'][option_index]
generation_status = cache_entry.get('generation_status', {})
status = generation_status.get(option_index, 'pending')
# 🔧 修复:确保图片和文本一起返回
# 如果状态是 'text_completed',说明图片还在生成,需要等待
if status == 'text_completed':
# 检查是否有图片
scene_image = option_data_temp.get('scene_image')
if not scene_image or not scene_image.get('url'):
# 文本优先返回,图片改由现有异步链路(SSE / generate-scene-image)补齐
option_data = option_data_temp
print(f"⚡ 选项 {option_index} 文本已就绪,图片继续异步生成并通过现有补图链路返回")
else:
# 图片已生成,可以直接返回
option_data = option_data_temp
print(f"✅ 从缓存中读取场景 {scene_id} 的选项 {option_index} 的剧情(包含图片)")
elif status == 'completed':
# 完全完成,可以直接返回
option_data = option_data_temp
print(f"✅ 从缓存中读取场景 {scene_id} 的选项 {option_index} 的剧情(包含图片)")
else:
# 其他状态,也尝试返回(可能有数据)
option_data = option_data_temp
print(f"✅ 从缓存中读取场景 {scene_id} 的选项 {option_index} 的剧情")
# 如果数据已就绪(有图片),处理第二层生成逻辑
if option_data and not need_wait:
# 用户选择了选项,需要控制第二层生成
# 检查第二层是否已经开始生成
layer2_generating = cache_entry.get('layer2_generating', False)
if layer2_generating:
# 情况1a:第二层已经开始生成
# 检查当前正在生成的是哪个选项的第二层
current_layer2_option = cache_entry.get('current_layer2_option', None)
if current_layer2_option == option_index:
# 正在生成的是用户选择的选项的第二层,继续生成
print(f"✅ 正在生成选项 {option_index} 的第二层,继续生成")
else:
# 正在生成的不是用户选择的选项的第二层,停止生成
print(f"⏹️ 停止生成选项 {current_layer2_option} 的第二层(用户选择了选项 {option_index})")
cache_entry['layer2_cancel'] = True
# 保存线程引用,在释放锁后等待(避免死锁)
layer2_thread_to_wait = cache_entry.get('layer2_thread')
else:
# 情况1b:第二层还未开始生成
# 设置标志,只生成用户选择的选项的第二层
print(f"📝 第二层还未开始生成,将只为选项 {option_index} 生成第二层")
cache_entry['layer2_selected_option'] = option_index
cache_entry['layer2_cancel'] = False
# 情况2:缓存中没有该选项的数据,检查生成状态
elif 'generation_status' in cache_entry:
generation_status = cache_entry.get('generation_status', {})
status = generation_status.get(option_index, 'pending')
if status == 'generating':
# 情况2a:正在生成中,等待生成完成
print(f"⏳ 选项 {option_index} 正在生成中,等待完成...")
print(f" - 当前缓存中的 layer1 选项索引:{list(cache_entry.get('layer1', {}).keys())}")
print(f" - 当前生成状态:{generation_status}")
need_wait = True
# 获取对应的事件对象
events = cache_entry.setdefault('generation_events', {})
if option_index not in events:
events[option_index] = threading.Event()
print(f" - 创建了选项 {option_index} 的等待事件")
else:
print(f" - 使用已存在的选项 {option_index} 的等待事件")
wait_event = events[option_index]
elif status == 'pending':
# 情况2b:还未开始生成,优先生成该选项
print(f"🚀 选项 {option_index} 还未生成,优先生成...")
# 标记需要取消其他未开始的生成
cache_entry['should_cancel'] = True
# 如果用户选择的选项还未生成,标记为高优先级
generation_status[option_index] = 'generating'
# 创建事件对象
events = cache_entry.setdefault('generation_events', {})
if option_index not in events:
events[option_index] = threading.Event()
wait_event = events[option_index]
# 启动单个选项的生成任务(优先生成)
def generate_selected_option():
try:
result = _generate_single_option(option_index, option, global_state)
if isinstance(result, dict):
opt_data = result.get('data', result)
else:
opt_data = result
with cache_lock:
if scene_id in pregeneration_cache:
cache_entry = pregeneration_cache[scene_id]
if 'layer1' not in cache_entry:
cache_entry['layer1'] = {}
cache_entry['layer1'][option_index] = opt_data
generation_status = cache_entry.setdefault('generation_status', {})
generation_status[option_index] = 'completed'
# 触发等待事件
events = cache_entry.get('generation_events', {})
if option_index in events:
events[option_index].set()
print(f"✅ 选项 {option_index} 优先生成完成")
except Exception as e:
print(f"❌ 优先生成选项 {option_index} 失败:{str(e)}")
with cache_lock:
if scene_id in pregeneration_cache:
events = pregeneration_cache[scene_id].get('generation_events', {})
if option_index in events:
events[option_index].set()
thread = threading.Thread(target=generate_selected_option, daemon=True)
thread.start()
need_wait = True
else:
# 情况3:scene_id不在缓存中,可能是第一次选择(前端传入了新生成的sceneId)
# 尝试从initial缓存中查找(第一次的选项数据在initial缓存中)
print(f"⚠️ [generate-option] 场景 {scene_id} 不在缓存中!")
print(f" - 前端传入的 scene_id:{scene_id}")
print(f" - 缓存中存在的 scene_id:{list(pregeneration_cache.keys())}")
print(f" - 尝试从initial缓存查找...")
if 'initial' in pregeneration_cache:
initial_cache = pregeneration_cache['initial']
if initial_cache.get('completed', False):
layer1_data = initial_cache.get('layer1', {})
if option_index in layer1_data:
option_data = layer1_data[option_index]
print(f"✅ 从initial缓存中读取选项 {option_index} 的剧情(第一次选择)")
else:
print(f"⚠️ initial缓存中也没有选项 {option_index} 的数据")
else:
print(f"⚠️ initial缓存还未完成生成")
# 🔧 容错增强:如果 scene_id 未命中且 initial 也没有该选项数据,则按需启动该选项生成并等待。
# 目的:避免因“首次不预生成 layer1”或“前端预生成请求尚未到达”导致返回默认/空数据。
if not option_data:
print(f"🚀 [generate-option] 缓存未命中,按需生成选项 {option_index}(scene_id={scene_id})...")
# 初始化该 scene_id 的缓存条目(与预生成结构一致)
pregeneration_cache[scene_id] = {
'layer1': {},
'layer2': {},
'generation_status': {},
'generation_events': {},
'should_cancel': False,
'current_generating_index': None,
'layer2_generating': False,
'layer2_cancel': False,
'layer2_selected_option': None,
'layer2_thread': None,
'current_layer2_option': None
}
cache_entry = pregeneration_cache[scene_id]
generation_status = cache_entry['generation_status']
generation_status[option_index] = 'generating'
events = cache_entry['generation_events']
if option_index not in events:
events[option_index] = threading.Event()
wait_event = events[option_index]
def generate_selected_option_for_missing_scene():
try:
result = _generate_single_option(option_index, option, global_state)
if isinstance(result, dict):
opt_data = result.get('data', result)
else:
opt_data = result
with cache_lock:
if scene_id in pregeneration_cache:
entry = pregeneration_cache[scene_id]
entry.setdefault('layer1', {})[option_index] = opt_data
entry.setdefault('generation_status', {})[option_index] = 'completed'
evs = entry.get('generation_events', {})
if option_index in evs:
evs[option_index].set()
print(f"✅ [generate-option] 按需生成完成:scene_id={scene_id}, option_index={option_index}")
except Exception as e:
print(f"❌ [generate-option] 按需生成失败:scene_id={scene_id}, option_index={option_index}, err={str(e)}")
with cache_lock:
if scene_id in pregeneration_cache:
entry = pregeneration_cache[scene_id]
entry.setdefault('generation_status', {})[option_index] = 'failed'
evs = entry.get('generation_events', {})
if option_index in evs:
evs[option_index].set()
thread = threading.Thread(target=generate_selected_option_for_missing_scene, daemon=True)
thread.start()
need_wait = True
# 在释放锁后等待第二层线程退出(避免死锁)
if layer2_thread_to_wait and layer2_thread_to_wait.is_alive():
# 等待线程退出(最多等待2秒)
layer2_thread_to_wait.join(timeout=2.0)
# 如果需要等待,则等待生成完成
if need_wait and wait_event:
try:
# 等待超时(默认300秒,可通过环境变量调节),避免前端卡死太久
# 说明:前端对 /generate-option 的默认超时为 5 分钟,因此这里默认 300s 与其对齐。
import time
wait_timeout = int(os.getenv("OPTION_WAIT_TIMEOUT_SECONDS", "300"))
start_wait_ts = time.time()
print(f"⏳ [generate-option] 开始等待选项 {option_index} 生成完成(超时:{wait_timeout}秒)...")
event_triggered = wait_event.wait(timeout=wait_timeout)
if event_triggered:
print(f"✅ [generate-option] 等待事件已触发,选项 {option_index} 生成完成")
else:
print(f"⚠️ [generate-option] 等待超时({wait_timeout}秒),选项 {option_index} 可能仍在生成中")
# 再次尝试从缓存读取(重要:不要在持锁状态下 sleep/wait,避免阻塞图片线程写回缓存)
if not scene_id or scene_id == 'initial':
with cache_lock:
if 'initial' in pregeneration_cache:
initial_cache = pregeneration_cache['initial']
if initial_cache.get('completed', False):
if option_index == 0 and option == "开始游戏":
initial_scene = initial_cache.get('initial_scene', '')
initial_scene_image = initial_cache.get('initial_scene_image', None)
initial_options = initial_cache.get('initial_options', [])
option_data = {
"scene": initial_scene,
"scene_image": initial_scene_image,
"next_options": initial_options,
"flow_update": {},
"deep_background_links": {}
}
else:
layer1_data = initial_cache.get('layer1', {})
if option_index in layer1_data:
option_data = layer1_data[option_index]
else:
option_data_temp = None
status = 'pending'
scene_image = None
with cache_lock:
if scene_id in pregeneration_cache:
cache_entry = pregeneration_cache[scene_id]
option_data_temp = cache_entry.get('layer1', {}).get(option_index)
status = cache_entry.get('generation_status', {}).get(option_index, 'pending')
if isinstance(option_data_temp, dict):
scene_image = option_data_temp.get('scene_image')
if isinstance(option_data_temp, dict):
if status == 'completed' and scene_image and scene_image.get('url'):
option_data = option_data_temp
elif status == 'text_completed':
option_data = option_data_temp
else:
option_data = option_data_temp
# 🆕 关键修复:如果事件触发后仍未拿到 option_data,不要立即“同步再生成”,而是继续等待正在进行的预生成写回缓存
# - 常见场景:后台线程仍在进行 LLM/图片生成,事件触发/超时后短时间内数据尚未写入
# - 这里做一个“剩余时间内轮询”,确保优先等待预生成完成再返回
if not option_data and scene_id and scene_id != 'initial':
poll_interval = float(os.getenv("OPTION_WAIT_POLL_SECONDS", "0.5"))
while time.time() - start_wait_ts < wait_timeout:
with cache_lock:
cache_entry = pregeneration_cache.get(scene_id)
if not cache_entry:
break
status = cache_entry.get('generation_status', {}).get(option_index, 'pending')
option_data_temp = cache_entry.get('layer1', {}).get(option_index)
if isinstance(option_data_temp, dict):
option_data = option_data_temp
break
if status in ['failed', 'cancelled']:
break
time.sleep(poll_interval)
# 如果等待后仍然没有:
# 不要返回 error + message(前端会把 message 当作剧情展示,并触发 /generate-scene-image,导致“生成超时”被画进图里)
# 这里返回一个“安全兜底”的 optionData,让游戏可以继续,同时避免把错误文案喂给生图。
if not option_data:
print(f"⚠️ [generate-option] 等待预生成到期仍未拿到 option_data,返回安全兜底数据(scene_id={scene_id}, option_index={option_index})")
option_data = {
"scene": "当前内容生成耗时较长,但你仍可以继续推进剧情。你决定先观察局势并寻找下一步行动方向。",
"next_options": ["继续前进", "查看周围环境"],
"flow_update": {
"characters": {},
"environment": {},
"quest_progress": "继续推进",
"chapter_conflict_solved": False
},
"deep_background_links": {}
}
except Exception as e:
print(f"❌ 等待生成时发生错误:{str(e)}")
return jsonify({
"status": "error",
"message": f"等待生成失败:{str(e)}"
})
block_for_image = os.getenv("GENERATE_OPTION_BLOCK_FOR_IMAGE", "0").strip() in ("1", "true", "True")
if block_for_image and option_data and scene_id and scene_id != 'initial':
scene_image = option_data.get('scene_image')
if not scene_image or not scene_image.get('url'):
print(f"⏳ 文本数据已就绪,但图片还在生成中,等待图片生成完成...")
import time
max_image_wait = 60
start_time = time.time()
while time.time() - start_time < max_image_wait:
time.sleep(0.5)
with cache_lock:
if scene_id in pregeneration_cache:
cache_entry = pregeneration_cache[scene_id]
if option_index in cache_entry.get('layer1', {}):
option_data_temp = cache_entry['layer1'][option_index]
status = cache_entry.get('generation_status', {}).get(option_index, 'pending')
scene_image_temp = option_data_temp.get('scene_image')
if status == 'completed' and scene_image_temp and scene_image_temp.get('url'):
option_data = option_data_temp
print(f"✅ 图片生成完成,数据已就绪(包含图片)")
break
if not option_data.get('scene_image') or not option_data.get('scene_image', {}).get('url'):
print(f"⚠️ 图片生成超时,但继续返回文本数据(图片可能稍后生成)")
# 如果仍然没有数据(不应该发生,但做容错处理)
if not option_data:
print(f"⚠️ 所有方法都失败,使用默认数据")
option_data = {
"scene": f"你选择了:{option}。在你的努力下,你取得了一些进展。",
"next_options": ["继续前进", "查看当前状态", "返回上一步", "探索周围环境"],
"flow_update": {
"characters": {},