wzh0617 commited on
Commit
9942d7a
·
1 Parent(s): 9e03a34

Upload 6 files

Browse files
Files changed (4) hide show
  1. app.py +363 -119
  2. state_manager.py +46 -4
  3. story_engine.py +664 -51
  4. utils.py +41 -2
app.py CHANGED
@@ -52,12 +52,13 @@ def restart_game() -> tuple:
52
  (空聊天历史, 初始状态面板, 隐藏选项按钮×3, 空游戏会话,
53
  禁用文本输入框, 重置角色名称)
54
  """
 
55
  return (
56
  [], # 清空聊天历史
57
  "## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
58
- gr.update(visible=False, interactive=False), # 隐藏选项按钮1
59
- gr.update(visible=False, interactive=False), # 隐藏选项按钮2
60
- gr.update(visible=False, interactive=False), # 隐藏选项按钮3
61
  {}, # 清空游戏会话
62
  gr.update(value="", interactive=False), # 禁用并清空文本输入
63
  gr.update(value="旅人"), # 重置角色名称
@@ -69,12 +70,11 @@ def restart_game() -> tuple:
69
  # ============================================================
70
 
71
 
72
- def start_game(player_name: str, game_session: dict) -> tuple:
73
  """
74
- 开始新游戏:生成开场叙事。
75
 
76
- Returns:
77
- (聊天历史, 状态面板文本, 选项按钮文本, 游戏会话)
78
  """
79
  if not player_name.strip():
80
  player_name = "旅人"
@@ -83,58 +83,89 @@ def start_game(player_name: str, game_session: dict) -> tuple:
83
  game_session = create_new_game(player_name)
84
  game_session["started"] = True
85
 
86
- # 生成开场
87
- result = game_session["story"].generate_opening()
 
 
88
 
89
- # 保存当前选项
90
- game_session["current_options"] = result.get("options", [])
 
 
 
 
91
 
92
- # 构建聊天历史
93
- story_text = result["story_text"]
94
- options_text = _format_options(result.get("options", []))
95
- full_message = f"{story_text}\n\n{options_text}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- chat_history = [{"role": "assistant", "content": full_message}]
 
98
 
99
- # 状态面板
100
- status_text = _format_status_panel(game_session["game_state"])
 
 
101
 
102
- # 选项按钮更新(静态按钮 + 动态更新模式)
103
- btn_updates = _get_button_updates(result.get("options", []))
 
104
 
105
- return (
106
- chat_history,
107
- status_text,
108
  btn_updates[0], btn_updates[1], btn_updates[2],
109
  game_session,
110
- gr.update(interactive=True), # 文本输入框
111
  )
112
 
113
 
114
- def process_user_input(user_input: str, chat_history: list, game_session: dict) -> tuple:
115
  """
116
- 处理用户文本输入。
117
 
118
  流程:
119
  1. NLU 引擎解析意图
120
- 2. 叙事引擎生成故事
121
- 3. 更新 UI
122
  """
123
  if not game_session or not game_session.get("started"):
124
  chat_history = chat_history or []
125
  chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
126
- return (
 
127
  chat_history, "",
128
- gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
129
  game_session,
130
  )
 
131
 
132
  if not user_input.strip():
133
- return (
134
  chat_history, _format_status_panel(game_session["game_state"]),
135
  gr.update(), gr.update(), gr.update(),
136
  game_session,
137
  )
 
138
 
139
  gs: GameState = game_session["game_state"]
140
  nlu: NLUEngine = game_session["nlu"]
@@ -144,61 +175,103 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict)
144
  if gs.is_game_over():
145
  chat_history.append({"role": "user", "content": user_input})
146
  chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
147
- return (
148
  chat_history,
149
  _format_status_panel(gs),
150
  gr.update(value="重新开始", visible=True, interactive=True),
151
- gr.update(visible=False), gr.update(visible=False),
 
152
  game_session,
153
  )
 
154
 
155
  # 1. NLU 解析
156
  intent = nlu.parse_intent(user_input)
157
 
158
- # 2. 生成故事
159
- result = story.generate_story(intent)
160
-
161
- # 3. 更新选项
162
- game_session["current_options"] = result.get("options", [])
163
-
164
- # 4. 构建聊天消息
165
  chat_history.append({"role": "user", "content": user_input})
 
166
 
167
- # 变更日志
168
- change_log = result.get("change_log", [])
169
- log_text = ""
170
- if change_log:
171
- log_text = "\n".join(f" {c}" for c in change_log)
172
- log_text = f"\n\n**状态变化:**\n{log_text}"
173
-
174
- # 一致性问题
175
- issues = result.get("consistency_issues", [])
176
- issues_text = ""
177
- if issues:
178
- issues_text = "\n".join(f" {i}" for i in issues)
179
- issues_text = f"\n\n**一致性提示:**\n{issues_text}"
180
 
181
- # 选项文本
182
- options_text = _format_options(result.get("options", []))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
- full_message = f"{result['story_text']}{log_text}{issues_text}\n\n{options_text}"
185
- chat_history.append({"role": "assistant", "content": full_message})
 
186
 
187
- # 5. 更新状态面板
188
- status_text = _format_status_panel(gs)
189
- btn_updates = _get_button_updates(result.get("options", []))
190
 
191
- return (
192
- chat_history,
193
- status_text,
194
- btn_updates[0], btn_updates[1], btn_updates[2],
195
- game_session,
196
- )
 
197
 
198
 
199
- def process_option_click(option_idx: int, chat_history: list, game_session: dict) -> tuple:
200
  """
201
- 处理玩家点击选项按钮。
202
 
203
  Args:
204
  option_idx: 选项索引 (0, 1, 2)
@@ -206,20 +279,23 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
206
  if not game_session or not game_session.get("started"):
207
  chat_history = chat_history or []
208
  chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
209
- return (
 
210
  chat_history, "",
211
- gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
212
  game_session,
213
  )
 
214
 
215
  options = game_session.get("current_options", [])
216
  if option_idx >= len(options):
217
- return (
218
  chat_history,
219
  _format_status_panel(game_session["game_state"]),
220
  gr.update(), gr.update(), gr.update(),
221
  game_session,
222
  )
 
223
 
224
  selected_option = options[option_idx]
225
  gs: GameState = game_session["game_state"]
@@ -227,76 +303,234 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
227
 
228
  # 检查特殊选项:重新开始
229
  if selected_option.get("action_type") == "RESTART":
230
- full_result = start_game(gs.player.name, game_session)
231
- # start_game 返回 7 个值(含 user_input 更新),此处只需前 6 个
232
- return full_result[:6]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
  # 检查特殊选项:退出
235
  if selected_option.get("action_type") == "QUIT":
236
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
237
  chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
238
- return (
239
  chat_history,
240
  _format_status_panel(gs),
241
  gr.update(value="重新开始", visible=True, interactive=True),
242
- gr.update(visible=False), gr.update(visible=False),
 
243
  game_session,
244
  )
 
245
 
246
- # 正常选项处理
247
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
 
248
 
249
- result = story.process_option_selection(selected_option)
250
- game_session["current_options"] = result.get("options", [])
251
-
252
- # 构建响应消息
253
- change_log = result.get("change_log", [])
254
- log_text = ""
255
- if change_log:
256
- log_text = "\n".join(f" {c}" for c in change_log)
257
- log_text = f"\n\n**状态变化:**\n{log_text}"
258
-
259
- options_text = _format_options(result.get("options", []))
260
- full_message = f"{result['story_text']}{log_text}\n\n{options_text}"
261
- chat_history.append({"role": "assistant", "content": full_message})
262
-
263
- status_text = _format_status_panel(gs)
264
- btn_updates = _get_button_updates(result.get("options", []))
265
-
266
- return (
267
  chat_history,
268
- status_text,
269
- btn_updates[0], btn_updates[1], btn_updates[2],
270
  game_session,
271
  )
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  # ============================================================
275
  # UI 辅助函数
276
  # ============================================================
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  def _format_options(options: list[dict]) -> str:
280
- """将选项列表格式化为可读的文本"""
281
  if not options:
282
  return ""
283
  lines = ["---", "**你的选择:**"]
284
- for opt in options:
285
- idx = opt.get("id", "?")
286
- text = opt.get("text", "未知选项")
 
 
 
 
 
287
  lines.append(f" **[{idx}]** {text}")
288
  return "\n".join(lines)
289
 
290
 
 
 
 
 
 
 
 
 
 
291
  def _get_button_updates(options: list[dict]) -> list:
292
- """从选项列表生成按钮的 gr.update() 对象(静态按钮 + 动态更新模式)"""
 
 
 
 
 
 
 
293
  updates = []
294
  for i in range(3):
295
- if i < len(options):
296
- text = options[i].get("text", "...")
297
- updates.append(gr.update(value=text, visible=True, interactive=True))
298
  else:
299
- updates.append(gr.update(value="", visible=False, interactive=False))
 
300
  return updates
301
 
302
 
@@ -464,7 +698,7 @@ def build_app() -> gr.Blocks:
464
  # ==================
465
  # 左侧:聊天区域
466
  # ==================
467
- with gr.Column(scale=3):
468
  # 玩家姓名输入 + 开始按钮
469
  with gr.Row():
470
  player_name_input = gr.Textbox(
@@ -476,12 +710,12 @@ def build_app() -> gr.Blocks:
476
  start_btn = gr.Button(
477
  "开始冒险",
478
  variant="primary",
479
- scale=1,
480
  )
481
  restart_btn = gr.Button(
482
  "重启冒险",
483
  variant="stop",
484
- scale=1,
485
  )
486
 
487
  # 聊天窗口
@@ -490,16 +724,16 @@ def build_app() -> gr.Blocks:
490
  height=480,
491
  )
492
 
493
- # 选项按钮
494
  with gr.Row():
495
  option_btn_1 = gr.Button(
496
- "选项 1", visible=False
497
  )
498
  option_btn_2 = gr.Button(
499
- "选项 2", visible=False
500
  )
501
  option_btn_3 = gr.Button(
502
- "选项 3", visible=False
503
  )
504
 
505
  # 自由输入
@@ -507,7 +741,7 @@ def build_app() -> gr.Blocks:
507
  user_input = gr.Textbox(
508
  label="自由输入(也可以直接点击上方选项)",
509
  placeholder="输入你想做的事情,例如:和村长说话、攻击哥布林、搜索这个区域...",
510
- scale=4,
511
  interactive=False,
512
  )
513
  send_btn = gr.Button("发送", variant="primary", scale=1)
@@ -515,7 +749,7 @@ def build_app() -> gr.Blocks:
515
  # ==================
516
  # 右侧:状态面板
517
  # ==================
518
- with gr.Column(scale=1):
519
  status_panel = gr.Markdown(
520
  value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
521
  label="角色状态",
@@ -575,9 +809,19 @@ def build_app() -> gr.Blocks:
575
  outputs=[user_input],
576
  )
577
 
578
- # 选项按钮点击
 
 
 
 
 
 
 
 
 
 
579
  option_btn_1.click(
580
- fn=lambda ch, gs: process_option_click(0, ch, gs),
581
  inputs=[chatbot, game_session],
582
  outputs=[
583
  chatbot, status_panel,
@@ -587,7 +831,7 @@ def build_app() -> gr.Blocks:
587
  )
588
 
589
  option_btn_2.click(
590
- fn=lambda ch, gs: process_option_click(1, ch, gs),
591
  inputs=[chatbot, game_session],
592
  outputs=[
593
  chatbot, status_panel,
@@ -597,7 +841,7 @@ def build_app() -> gr.Blocks:
597
  )
598
 
599
  option_btn_3.click(
600
- fn=lambda ch, gs: process_option_click(2, ch, gs),
601
  inputs=[chatbot, game_session],
602
  outputs=[
603
  chatbot, status_panel,
 
52
  (空聊天历史, 初始状态面板, 隐藏选项按钮×3, 空游戏会话,
53
  禁用文本输入框, 重置角色名称)
54
  """
55
+ loading = _get_loading_button_updates()
56
  return (
57
  [], # 清空聊天历史
58
  "## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
59
+ loading[0], # 占位选项按钮1
60
+ loading[1], # 占位选项按钮2
61
+ loading[2], # 占位选项按钮3
62
  {}, # 清空游戏会话
63
  gr.update(value="", interactive=False), # 禁用并清空文本输入
64
  gr.update(value="旅人"), # 重置角色名称
 
70
  # ============================================================
71
 
72
 
73
+ def start_game(player_name: str, game_session: dict):
74
  """
75
+ 开始新游戏:流式生成开场叙事。
76
 
77
+ 使用生成器 yield 实现流式输出,让用户看到文字逐步出现。
 
78
  """
79
  if not player_name.strip():
80
  player_name = "旅人"
 
83
  game_session = create_new_game(player_name)
84
  game_session["started"] = True
85
 
86
+ # 初始 yield:显示加载状态,按钮保持可见但禁用
87
+ chat_history = [{"role": "assistant", "content": "⏳ 正在生成开场..."}]
88
+ status_text = _format_status_panel(game_session["game_state"])
89
+ loading = _get_loading_button_updates()
90
 
91
+ yield (
92
+ chat_history, status_text,
93
+ loading[0], loading[1], loading[2],
94
+ game_session,
95
+ gr.update(interactive=False),
96
+ )
97
 
98
+ # 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
99
+ story_text = ""
100
+ final_result = None
101
+
102
+ for update in game_session["story"].generate_opening_stream():
103
+ if update["type"] == "story_chunk":
104
+ story_text = update["text"]
105
+ chat_history[-1]["content"] = story_text
106
+ yield (
107
+ chat_history, status_text,
108
+ loading[0], loading[1], loading[2],
109
+ game_session,
110
+ gr.update(interactive=False),
111
+ )
112
+ elif update["type"] == "final":
113
+ final_result = update
114
+
115
+ # ★ 只在数据流完全结束后,从 final_result 中提取选项
116
+ if final_result:
117
+ story_text = final_result.get("story_text", story_text)
118
+ options = final_result.get("options", [])
119
+ else:
120
+ options = []
121
 
122
+ # 安全兜底:强制确保恰好 3 个选项
123
+ options = _ensure_min_options(options, 3)
124
 
125
+ # 最终 yield:显示完整文本 + 选项 + 启用按钮
126
+ game_session["current_options"] = options
127
+ options_text = _format_options(options)
128
+ full_message = f"{story_text}\n\n{options_text}"
129
 
130
+ chat_history[-1]["content"] = full_message
131
+ status_text = _format_status_panel(game_session["game_state"])
132
+ btn_updates = _get_button_updates(options)
133
 
134
+ yield (
135
+ chat_history, status_text,
 
136
  btn_updates[0], btn_updates[1], btn_updates[2],
137
  game_session,
138
+ gr.update(interactive=True),
139
  )
140
 
141
 
142
+ def process_user_input(user_input: str, chat_history: list, game_session: dict):
143
  """
144
+ 处理用户文本输入(流式版本)
145
 
146
  流程:
147
  1. NLU 引擎解析意图
148
+ 2. 叙事引擎流式生成故事
149
+ 3. 逐步更新 UI
150
  """
151
  if not game_session or not game_session.get("started"):
152
  chat_history = chat_history or []
153
  chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
154
+ loading = _get_loading_button_updates()
155
+ yield (
156
  chat_history, "",
157
+ loading[0], loading[1], loading[2],
158
  game_session,
159
  )
160
+ return
161
 
162
  if not user_input.strip():
163
+ yield (
164
  chat_history, _format_status_panel(game_session["game_state"]),
165
  gr.update(), gr.update(), gr.update(),
166
  game_session,
167
  )
168
+ return
169
 
170
  gs: GameState = game_session["game_state"]
171
  nlu: NLUEngine = game_session["nlu"]
 
175
  if gs.is_game_over():
176
  chat_history.append({"role": "user", "content": user_input})
177
  chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
178
+ yield (
179
  chat_history,
180
  _format_status_panel(gs),
181
  gr.update(value="重新开始", visible=True, interactive=True),
182
+ gr.update(value="...", visible=True, interactive=False),
183
+ gr.update(value="...", visible=True, interactive=False),
184
  game_session,
185
  )
186
+ return
187
 
188
  # 1. NLU 解析
189
  intent = nlu.parse_intent(user_input)
190
 
191
+ # 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
 
 
 
 
 
 
192
  chat_history.append({"role": "user", "content": user_input})
193
+ chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
194
 
195
+ # 按钮保持可见但禁用,防止流式期间点击
196
+ loading = _get_loading_button_updates()
197
+ yield (
198
+ chat_history,
199
+ _format_status_panel(gs),
200
+ loading[0], loading[1], loading[2],
201
+ game_session,
202
+ )
 
 
 
 
 
203
 
204
+ # 3. 流式生成故事
205
+ final_result = None
206
+ for update in story.generate_story_stream(intent):
207
+ if update["type"] == "story_chunk":
208
+ chat_history[-1]["content"] = update["text"]
209
+ yield (
210
+ chat_history,
211
+ _format_status_panel(gs),
212
+ loading[0], loading[1], loading[2],
213
+ game_session,
214
+ )
215
+ elif update["type"] == "final":
216
+ final_result = update
217
+
218
+ # 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
219
+ if final_result:
220
+ # ★ 安全兜底:强制确保恰好 3 个选项
221
+ options = _ensure_min_options(final_result.get("options", []), 3)
222
+ game_session["current_options"] = options
223
+
224
+ change_log = final_result.get("change_log", [])
225
+ log_text = ""
226
+ if change_log:
227
+ log_text = "\n".join(f" {c}" for c in change_log)
228
+ log_text = f"\n\n**状态变化:**\n{log_text}"
229
+
230
+ issues = final_result.get("consistency_issues", [])
231
+ issues_text = ""
232
+ if issues:
233
+ issues_text = "\n".join(f" {i}" for i in issues)
234
+ issues_text = f"\n\n**一致性提示:**\n{issues_text}"
235
+
236
+ options_text = _format_options(options)
237
+ full_message = f"{final_result['story_text']}{log_text}{issues_text}\n\n{options_text}"
238
+ chat_history[-1]["content"] = full_message
239
+
240
+ status_text = _format_status_panel(gs)
241
+ btn_updates = _get_button_updates(options)
242
+
243
+ yield (
244
+ chat_history,
245
+ status_text,
246
+ btn_updates[0], btn_updates[1], btn_updates[2],
247
+ game_session,
248
+ )
249
+ else:
250
+ # ★ 兜底:final_result 为空,说明流式生成未产生 final ��件
251
+ logger.warning("流式生成未产生 final 事件,使用兜底文本")
252
+ fallback_text = "你环顾四周,思考着接下来该做什么..."
253
+ fallback_options = _ensure_min_options([], 3)
254
+ game_session["current_options"] = fallback_options
255
 
256
+ options_text = _format_options(fallback_options)
257
+ full_message = f"{fallback_text}\n\n{options_text}"
258
+ chat_history[-1]["content"] = full_message
259
 
260
+ status_text = _format_status_panel(gs)
261
+ btn_updates = _get_button_updates(fallback_options)
 
262
 
263
+ yield (
264
+ chat_history,
265
+ status_text,
266
+ btn_updates[0], btn_updates[1], btn_updates[2],
267
+ game_session,
268
+ )
269
+ return
270
 
271
 
272
+ def process_option_click(option_idx: int, chat_history: list, game_session: dict):
273
  """
274
+ 处理玩家点击选项按钮(流式版本)
275
 
276
  Args:
277
  option_idx: 选项索引 (0, 1, 2)
 
279
  if not game_session or not game_session.get("started"):
280
  chat_history = chat_history or []
281
  chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
282
+ loading = _get_loading_button_updates()
283
+ yield (
284
  chat_history, "",
285
+ loading[0], loading[1], loading[2],
286
  game_session,
287
  )
288
+ return
289
 
290
  options = game_session.get("current_options", [])
291
  if option_idx >= len(options):
292
+ yield (
293
  chat_history,
294
  _format_status_panel(game_session["game_state"]),
295
  gr.update(), gr.update(), gr.update(),
296
  game_session,
297
  )
298
+ return
299
 
300
  selected_option = options[option_idx]
301
  gs: GameState = game_session["game_state"]
 
303
 
304
  # 检查特殊选项:重新开始
305
  if selected_option.get("action_type") == "RESTART":
306
+ # 重新开始时使用流式开场
307
+ game_session = create_new_game(gs.player.name)
308
+ game_session["started"] = True
309
+
310
+ chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
311
+ status_text = _format_status_panel(game_session["game_state"])
312
+ loading = _get_loading_button_updates()
313
+
314
+ yield (
315
+ chat_history, status_text,
316
+ loading[0], loading[1], loading[2],
317
+ game_session,
318
+ )
319
+
320
+ story_text = ""
321
+ restart_final = None
322
+
323
+ for update in game_session["story"].generate_opening_stream():
324
+ if update["type"] == "story_chunk":
325
+ story_text = update["text"]
326
+ chat_history[-1]["content"] = story_text
327
+ yield (
328
+ chat_history, status_text,
329
+ loading[0], loading[1], loading[2],
330
+ game_session,
331
+ )
332
+ elif update["type"] == "final":
333
+ restart_final = update
334
+
335
+ # ★ 只在流完全结束后提取选项
336
+ if restart_final:
337
+ story_text = restart_final.get("story_text", story_text)
338
+ restart_options = restart_final.get("options", [])
339
+ else:
340
+ restart_options = []
341
+
342
+ # ★ 安全兜底:强制确保恰好 3 个选项
343
+ restart_options = _ensure_min_options(restart_options, 3)
344
+ game_session["current_options"] = restart_options
345
+ options_text = _format_options(restart_options)
346
+ full_message = f"{story_text}\n\n{options_text}"
347
+ chat_history[-1]["content"] = full_message
348
+
349
+ status_text = _format_status_panel(game_session["game_state"])
350
+ btn_updates = _get_button_updates(restart_options)
351
+
352
+ yield (
353
+ chat_history, status_text,
354
+ btn_updates[0], btn_updates[1], btn_updates[2],
355
+ game_session,
356
+ )
357
+ return
358
 
359
  # 检查特殊选项:退出
360
  if selected_option.get("action_type") == "QUIT":
361
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
362
  chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
363
+ yield (
364
  chat_history,
365
  _format_status_panel(gs),
366
  gr.update(value="重新开始", visible=True, interactive=True),
367
+ gr.update(value="...", visible=True, interactive=False),
368
+ gr.update(value="...", visible=True, interactive=False),
369
  game_session,
370
  )
371
+ return
372
 
373
+ # 正常选项处理:流式生成
374
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
375
+ chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
376
 
377
+ # 按钮保持可见但禁用
378
+ loading = _get_loading_button_updates()
379
+ yield (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  chat_history,
381
+ _format_status_panel(gs),
382
+ loading[0], loading[1], loading[2],
383
  game_session,
384
  )
385
 
386
+ final_result = None
387
+ for update in story.process_option_selection_stream(selected_option):
388
+ if update["type"] == "story_chunk":
389
+ chat_history[-1]["content"] = update["text"]
390
+ yield (
391
+ chat_history,
392
+ _format_status_panel(gs),
393
+ loading[0], loading[1], loading[2],
394
+ game_session,
395
+ )
396
+ elif update["type"] == "final":
397
+ final_result = update
398
+
399
+ if final_result:
400
+ # ★ 安全兜底:强制确保恰好 3 个选项
401
+ options = _ensure_min_options(final_result.get("options", []), 3)
402
+ game_session["current_options"] = options
403
+
404
+ change_log = final_result.get("change_log", [])
405
+ log_text = ""
406
+ if change_log:
407
+ log_text = "\n".join(f" {c}" for c in change_log)
408
+ log_text = f"\n\n**状态变化:**\n{log_text}"
409
+
410
+ options_text = _format_options(options)
411
+ full_message = f"{final_result['story_text']}{log_text}\n\n{options_text}"
412
+ chat_history[-1]["content"] = full_message
413
+
414
+ status_text = _format_status_panel(gs)
415
+ btn_updates = _get_button_updates(options)
416
+
417
+ yield (
418
+ chat_history, status_text,
419
+ btn_updates[0], btn_updates[1], btn_updates[2],
420
+ game_session,
421
+ )
422
+ else:
423
+ # ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
424
+ logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
425
+ fallback_text = "你环顾四周,思考着接下来该做什么..."
426
+ fallback_options = _ensure_min_options([], 3)
427
+ game_session["current_options"] = fallback_options
428
+
429
+ options_text = _format_options(fallback_options)
430
+ full_message = f"{fallback_text}\n\n{options_text}"
431
+ chat_history[-1]["content"] = full_message
432
+
433
+ status_text = _format_status_panel(gs)
434
+ btn_updates = _get_button_updates(fallback_options)
435
+
436
+ yield (
437
+ chat_history, status_text,
438
+ btn_updates[0], btn_updates[1], btn_updates[2],
439
+ game_session,
440
+ )
441
+ return
442
+
443
 
444
  # ============================================================
445
  # UI 辅助函数
446
  # ============================================================
447
 
448
 
449
+ # 兜底默认选项(当解析出的选项不足 3 个时使用)
450
+ _FALLBACK_BUTTON_OPTIONS = [
451
+ {"id": 1, "text": "查看周围", "action_type": "EXPLORE"},
452
+ {"id": 2, "text": "等待一会", "action_type": "REST"},
453
+ {"id": 3, "text": "检查状态", "action_type": "EXPLORE"},
454
+ ]
455
+
456
+
457
+ def _ensure_min_options(options: list[dict], min_count: int = 3) -> list[dict]:
458
+ """
459
+ 强制确保选项列表至少有 min_count 个选项。
460
+ 不足时用通用兜底选项补全,作为 UI 层的最终安全网。
461
+ """
462
+ if not isinstance(options, list):
463
+ options = []
464
+
465
+ if len(options) >= min_count:
466
+ return options[:min_count]
467
+
468
+ # 用兜底选项补全
469
+ for fb in _FALLBACK_BUTTON_OPTIONS:
470
+ if len(options) >= min_count:
471
+ break
472
+ if not any(o.get("text") == fb["text"] for o in options):
473
+ options.append(fb.copy())
474
+
475
+ # 极端兜底:仍不足则用"继续探索"填充
476
+ while len(options) < min_count:
477
+ options.append({
478
+ "id": len(options) + 1,
479
+ "text": "继续探索",
480
+ "action_type": "EXPLORE",
481
+ })
482
+
483
+ # 重新编号
484
+ for i, opt in enumerate(options[:min_count], 1):
485
+ if isinstance(opt, dict):
486
+ opt["id"] = i
487
+
488
+ return options[:min_count]
489
+
490
+
491
  def _format_options(options: list[dict]) -> str:
492
+ """将选项列表格式化为可读的文本(纯文字,绝不显示 JSON)"""
493
  if not options:
494
  return ""
495
  lines = ["---", "**你的选择:**"]
496
+ for i, opt in enumerate(options):
497
+ # 安全提取:兼容 dict 和异常情况
498
+ if isinstance(opt, dict):
499
+ idx = opt.get("id", i + 1)
500
+ text = opt.get("text", "未知选项")
501
+ else:
502
+ idx = i + 1
503
+ text = str(opt)
504
  lines.append(f" **[{idx}]** {text}")
505
  return "\n".join(lines)
506
 
507
 
508
+ def _get_loading_button_updates() -> list:
509
+ """返回 3 个加载中占位按钮更新(始终可见,但禁用交互)"""
510
+ return [
511
+ gr.update(value="...", visible=True, interactive=False),
512
+ gr.update(value="...", visible=True, interactive=False),
513
+ gr.update(value="...", visible=True, interactive=False),
514
+ ]
515
+
516
+
517
  def _get_button_updates(options: list[dict]) -> list:
518
+ """从选项列表生成按钮的 gr.update() 对象,始终返回恰好 3 更新"""
519
+ # 确保 options 是列表
520
+ if not isinstance(options, list):
521
+ options = []
522
+
523
+ # ★ 安全兜底:强制补全到 3 个选项,确保按钮永远是 3 个
524
+ options = _ensure_min_options(options, 3)
525
+
526
  updates = []
527
  for i in range(3):
528
+ opt = options[i]
529
+ if isinstance(opt, dict):
530
+ text = opt.get("text", "...")
531
  else:
532
+ text = str(opt) if opt else "..."
533
+ updates.append(gr.update(value=text, visible=True, interactive=True))
534
  return updates
535
 
536
 
 
698
  # ==================
699
  # 左侧:聊天区域
700
  # ==================
701
+ with gr.Column(scale=10):
702
  # 玩家姓名输入 + 开始按钮
703
  with gr.Row():
704
  player_name_input = gr.Textbox(
 
710
  start_btn = gr.Button(
711
  "开始冒险",
712
  variant="primary",
713
+ scale=2,
714
  )
715
  restart_btn = gr.Button(
716
  "重启冒险",
717
  variant="stop",
718
+ scale=2,
719
  )
720
 
721
  # 聊天窗口
 
724
  height=480,
725
  )
726
 
727
+ # 选项按钮(始终可见,加载时显示占位符)
728
  with gr.Row():
729
  option_btn_1 = gr.Button(
730
+ "...", visible=True, interactive=False
731
  )
732
  option_btn_2 = gr.Button(
733
+ "...", visible=True, interactive=False
734
  )
735
  option_btn_3 = gr.Button(
736
+ "...", visible=True, interactive=False
737
  )
738
 
739
  # 自由输入
 
741
  user_input = gr.Textbox(
742
  label="自由输入(也可以直接点击上方选项)",
743
  placeholder="输入你想做的事情,例如:和村长说话、攻击哥布林、搜索这个区域...",
744
+ scale=5,
745
  interactive=False,
746
  )
747
  send_btn = gr.Button("发送", variant="primary", scale=1)
 
749
  # ==================
750
  # 右侧:状态面板
751
  # ==================
752
+ with gr.Column(scale=2, min_width=250):
753
  status_panel = gr.Markdown(
754
  value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
755
  label="角色状态",
 
809
  outputs=[user_input],
810
  )
811
 
812
+ # 选项按钮点击(需要使用 yield from 的生成器包装函数,
813
+ # 使 Gradio 能正确识别为流式输出)
814
+ def _option_click_0(ch, gs):
815
+ yield from process_option_click(0, ch, gs)
816
+
817
+ def _option_click_1(ch, gs):
818
+ yield from process_option_click(1, ch, gs)
819
+
820
+ def _option_click_2(ch, gs):
821
+ yield from process_option_click(2, ch, gs)
822
+
823
  option_btn_1.click(
824
+ fn=_option_click_0,
825
  inputs=[chatbot, game_session],
826
  outputs=[
827
  chatbot, status_panel,
 
831
  )
832
 
833
  option_btn_2.click(
834
+ fn=_option_click_1,
835
  inputs=[chatbot, game_session],
836
  outputs=[
837
  chatbot, status_panel,
 
841
  )
842
 
843
  option_btn_3.click(
844
+ fn=_option_click_2,
845
  inputs=[chatbot, game_session],
846
  outputs=[
847
  chatbot, status_panel,
state_manager.py CHANGED
@@ -757,14 +757,32 @@ class GameState:
757
  self.world.locations[new_loc].is_discovered = True
758
 
759
  # --- 物品变更 ---
 
 
 
760
  if "items_gained" in changes:
761
  for item in changes["items_gained"]:
762
- self.player.inventory.append(str(item))
 
 
 
 
 
 
 
 
 
 
 
763
  change_log.append(f"获得物品: {item}")
764
 
765
  if "items_lost" in changes:
766
  for item in changes["items_lost"]:
767
  item_str = str(item)
 
 
 
 
768
  if item_str in self.player.inventory:
769
  self.player.inventory.remove(item_str)
770
  change_log.append(f"失去物品: {item}")
@@ -901,8 +919,25 @@ class GameState:
901
  for slot, item_name in changes["equip"].items():
902
  if slot in self.player.equipment:
903
  old_item = self.player.equipment[slot]
904
- self.player.equipment[slot] = item_name
905
- change_log.append(f"装备 [{slot}]: {old_item} → {item_name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
 
907
  # --- 玩家称号变更 ---
908
  if "title_change" in changes:
@@ -1091,7 +1126,14 @@ class GameState:
1091
  "非消耗品(哨子、武器、工具、乐器、钥匙等可重复使用的物品)使用后仍然保留在背包中,"
1092
  "绝对不要将它们放入 items_lost。例如:吹响哨子后哨子仍在背包中;使用火把照明后火把仍在。\n"
1093
  "10. 【选项物品约束】生成的选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。"
1094
- "不要生成使用玩家不拥有的物品的选项。"
 
 
 
 
 
 
 
1095
  )
1096
 
1097
  # 组合完整 Prompt
 
757
  self.world.locations[new_loc].is_discovered = True
758
 
759
  # --- 物品变更 ---
760
+ # 货币关键词列表:这些物品不进背包,而是直接转换为金币
761
+ _CURRENCY_KEYWORDS = ["铜币", "银币", "铜钱", "银两", "金币", "货币", "钱袋", "钱币", "硬币"]
762
+
763
  if "items_gained" in changes:
764
  for item in changes["items_gained"]:
765
+ item_str = str(item)
766
+ # 检查是否为货币类物品 —— 如果是,跳过入背包(金币已通过 gold_change 处理)
767
+ is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS)
768
+ if is_currency:
769
+ # 如果 gold_change 没有设置,尝试自动补偿少量金币
770
+ if "gold_change" not in changes:
771
+ old_gold = self.player.gold
772
+ self.player.gold += 3 # 默认少量金币
773
+ change_log.append(f"金币: {old_gold} → {self.player.gold}")
774
+ logger.info(f"货币物品 '{item_str}' 已转换为金币,不放入背包")
775
+ continue
776
+ self.player.inventory.append(item_str)
777
  change_log.append(f"获得物品: {item}")
778
 
779
  if "items_lost" in changes:
780
  for item in changes["items_lost"]:
781
  item_str = str(item)
782
+ # 货币类物品也不需要从背包移除
783
+ is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS)
784
+ if is_currency:
785
+ continue
786
  if item_str in self.player.inventory:
787
  self.player.inventory.remove(item_str)
788
  change_log.append(f"失去物品: {item}")
 
919
  for slot, item_name in changes["equip"].items():
920
  if slot in self.player.equipment:
921
  old_item = self.player.equipment[slot]
922
+ new_item = item_name if item_name and str(item_name).lower() not in ("none", "null", "") else None
923
+
924
+ # 1. 如果旧装备栏有物品,卸下时放回背包
925
+ if old_item and old_item != "无":
926
+ if old_item not in self.player.inventory:
927
+ self.player.inventory.append(old_item)
928
+ logger.info(f"卸下装备 '{old_item}' 放回背包")
929
+
930
+ # 2. 如果要装备新物品,从背包中移除
931
+ if new_item:
932
+ new_item_str = str(new_item)
933
+ if new_item_str in self.player.inventory:
934
+ self.player.inventory.remove(new_item_str)
935
+ logger.info(f"从背包取出 '{new_item_str}' 装备到 [{slot}]")
936
+
937
+ self.player.equipment[slot] = new_item
938
+ display_old = old_item or "无"
939
+ display_new = new_item or "无"
940
+ change_log.append(f"装备 [{slot}]: {display_old} → {display_new}")
941
 
942
  # --- 玩家称号变更 ---
943
  if "title_change" in changes:
 
1126
  "非消耗品(哨子、武器、工具、乐器、钥匙等可重复使用的物品)使用后仍然保留在背包中,"
1127
  "绝对不要将它们放入 items_lost。例如:吹响哨子后哨子仍在背包中;使用火把照明后火把仍在。\n"
1128
  "10. 【选项物品约束】生成的选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。"
1129
+ "不要生成使用玩家不拥有的物品的选项。\n"
1130
+ '11. 【货币规则】游戏内货币统一为"金币",对应 gold_change 字段。严禁使用"铜币""银币""银两"等名称。'
1131
+ "任何钱财/财物类收获(如击败怪物掉落的钱币、交易获得的货款等)必须通过 gold_change 表达,"
1132
+ "严禁将任何种类的钱币放入 items_gained。\n"
1133
+ '12. 【装备规则】装备物品时必须使用 equip 字段指定槽位和物品名称(如 "weapon": "小刀")。'
1134
+ "系统会自动将装备的物品从背包移到装备栏,并将旧装备放回背包。"
1135
+ "因此装备操作时不要在 items_lost/items_gained 中重复处理该物品。"
1136
+ "合法槽位:weapon / armor / accessory / helmet / boots。卸下装备时将对应槽位设为 null。"
1137
  )
1138
 
1139
  # 组合完整 Prompt
story_engine.py CHANGED
@@ -20,7 +20,7 @@ import logging
20
  import re
21
  from typing import Optional
22
 
23
- from utils import call_qwen, safe_json_call, extract_json_from_text, DEFAULT_MODEL
24
  from state_manager import GameState
25
 
26
  logger = logging.getLogger("StoryWeaver")
@@ -84,6 +84,24 @@ def _merge_change_logs(tick_log: list[str], action_log: list[str]) -> list[str]:
84
  return remaining_tick + merged_results
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  # ============================================================
88
  # Prompt 模板设计
89
  # ============================================================
@@ -153,7 +171,8 @@ OUTLINE_SYSTEM_PROMPT_TEMPLATE = """你是一个专业的 RPG 叙事引擎的规
153
  - 确保所有数值变更合理(例如战斗伤害应考虑攻防差值)
154
  - 【关键】所有数值变更必须精确,禁止使用“恢复一些”“大幅降低”等模糊描述,必须给出精确数字(如 hunger_change: 14)。
155
  - 【关键】time_change 字段只允许以下值之一:"清晨""上午""正午""下午""黄昏""夜晚""深夜",不要填写其他格式(如"30分钟""两小时后"等都是非法值)。如果本回合没有发生时间跳跃(例如休息、等待、长途旅行等),请设为 null(系统会自动推进一个时段)。只有当剧情需要跳跃多个时段时才设置此字段。
156
- - 【关键】游戏内的货币单位"金币",对应 gold_change 字段。不要使用"银币""铜币""银两"等其他货币名称,所有交易都以"金币"结算
 
157
  - 【关键】status_effects_added 中每个效果必须是一个对象,且必须包含以下字段:
158
  - "name": 中文效果名称(如"中毒""祝福""饱腹"),必须是中文,不要使用英文字段名
159
  - "description": 获得原因的简短说明(如"食用面包后精力充沛""被毒蛇咬伤")
@@ -184,7 +203,15 @@ OUTLINE_SYSTEM_PROMPT_TEMPLATE = """你是一个专业的 RPG 叙事引擎的规
184
  # - 选项应覆盖不同策略(如激进/保守/探索)
185
  # - 中等温度 (0.8) 增加文学创意
186
  # ------------------------------------------------------------
187
- NARRATIVE_SYSTEM_PROMPT_TEMPLATE = """你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写剧情文本。
 
 
 
 
 
 
 
 
188
 
189
  {world_state}
190
 
@@ -253,7 +280,15 @@ DEATH_NARRATIVE_PROMPT = """你是一个才华横溢的奇幻小说家。玩家
253
  # ------------------------------------------------------------
254
  # 开场叙事 Prompt
255
  # ------------------------------------------------------------
256
- OPENING_NARRATIVE_PROMPT = """你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写开场白。
 
 
 
 
 
 
 
 
257
 
258
  {world_state}
259
 
@@ -278,6 +313,101 @@ OPENING_NARRATIVE_PROMPT = """你是一个经验丰富的奇幻小说家,正
278
  """
279
 
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  class StoryEngine:
282
  """
283
  叙事引擎 —— 负责故事内容的生成
@@ -583,54 +713,147 @@ class StoryEngine:
583
  "consistency_issues": [],
584
  }
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  def _parse_story_response(self, raw_text: str) -> tuple[str, list[dict]]:
587
  """
588
  解析 LLM 返回的故事响应,分离文本和选项。
589
 
590
- 预期格式
591
- ---STORY_TEXT---
592
- (故事文本)
593
- ---OPTIONS_JSON---
594
- [JSON 选项数组]
595
-
596
- 降级策略:如果格式不符,尝试直接提取 JSON 数组。
597
  """
 
 
 
598
  story_text = ""
599
  options = []
600
 
601
- if "---STORY_TEXT---" in raw_text and "---OPTIONS_JSON---" in raw_text:
602
- # 标准格式解析:严格取两个标记之间的内容,避免LLM在标记前重复输出导致文本重复
603
- story_start = raw_text.index("---STORY_TEXT---") + len("---STORY_TEXT---")
604
- options_start = raw_text.index("---OPTIONS_JSON---")
605
- story_part = raw_text[story_start:options_start].strip()
606
- options_part = raw_text[options_start + len("---OPTIONS_JSON---"):].strip()
 
 
 
 
 
 
 
607
 
608
  story_text = story_part
609
  parsed_options = extract_json_from_text(options_part)
610
- if isinstance(parsed_options, list):
611
  options = parsed_options
612
- else:
613
- # 降级尝试找到 JSON 数组作为选项
614
- logger.warning("响应格式不标准,尝试降级解析")
615
- # 查最后出现的 JSON 数组
616
- import re
617
- array_matches = list(re.finditer(r"\[[\s\S]*?\]", raw_text))
618
- if array_matches:
619
- last_match = array_matches[-1]
620
- options_text = last_match.group()
621
- parsed = extract_json_from_text(options_text)
622
- if isinstance(parsed, list):
623
- options = parsed
624
- story_text = raw_text[:last_match.start()].strip()
625
- else:
626
- story_text = raw_text
627
- else:
628
- story_text = raw_text
 
 
 
 
 
 
 
629
 
630
  # 确保至少有默认选项
631
  if not options:
 
632
  options = self._generate_default_options()
633
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  return story_text, options
635
 
636
  def _generate_default_options(self) -> list[dict]:
@@ -663,6 +886,34 @@ class StoryEngine:
663
 
664
  return default_options[:3]
665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  def _fallback_response(self, player_intent: dict, tick_log: list[str] | None = None) -> dict:
667
  """
668
  降级响应:当大纲生成完全失败时,提供基本响应。
@@ -814,22 +1065,8 @@ class StoryEngine:
814
  if is_valid:
815
  validated.append(opt)
816
 
817
- # 确保仍然有 3 个选项
818
- if len(validated) < 3:
819
- defaults = self._generate_default_options()
820
- for d in defaults:
821
- if len(validated) >= 3:
822
- break
823
- # 避免重复选项
824
- if not any(v.get("text") == d["text"] for v in validated):
825
- d["id"] = len(validated) + 1
826
- validated.append(d)
827
-
828
- # 重新编号
829
- for i, opt in enumerate(validated[:3], 1):
830
- opt["id"] = i
831
-
832
- return validated[:3]
833
 
834
  def process_option_selection(self, option: dict) -> dict:
835
  """
@@ -850,3 +1087,379 @@ class StoryEngine:
850
  "raw_input": option.get("text", ""),
851
  }
852
  return self.generate_story(intent)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  import re
21
  from typing import Optional
22
 
23
+ from utils import call_qwen, call_qwen_stream, safe_json_call, extract_json_from_text, DEFAULT_MODEL
24
  from state_manager import GameState
25
 
26
  logger = logging.getLogger("StoryWeaver")
 
84
  return remaining_tick + merged_results
85
 
86
 
87
+ def _normalize_markers(text: str) -> str:
88
+ """
89
+ 标准化 LLM 输出中的分隔标记,处理常见变体格式。
90
+
91
+ LLM 有时会输出与预期略有不同的标记格式,例如:
92
+ - 多余的空格: "--- STORY_TEXT ---"
93
+ - 不同的连字符数量: "----STORY_TEXT----"
94
+ - 大小写变化: "---story_text---"
95
+ - 下划线变空格: "---STORY TEXT---"
96
+ 此函数将这些变体统一为标准格式。
97
+ """
98
+ text = re.sub(r'-{2,}\s*STORY[_ ]?TEXT\s*-{2,}', '---STORY_TEXT---', text, flags=re.IGNORECASE)
99
+ text = re.sub(r'-{2,}\s*OPTIONS[_ ]?JSON\s*-{2,}', '---OPTIONS_JSON---', text, flags=re.IGNORECASE)
100
+ text = re.sub(r'-{2,}\s*STATE[_ ]?JSON\s*-{2,}', '---STATE_JSON---', text, flags=re.IGNORECASE)
101
+ text = re.sub(r'-{2,}\s*THINKING\s*-{2,}', '---THINKING---', text, flags=re.IGNORECASE)
102
+ return text
103
+
104
+
105
  # ============================================================
106
  # Prompt 模板设计
107
  # ============================================================
 
171
  - 确保所有数值变更合理(例如战斗伤害应考虑攻防差值)
172
  - 【关键】所有数值变更必须精确,禁止使用“恢复一些”“大幅降低”等模糊描述,必须给出精确数字(如 hunger_change: 14)。
173
  - 【关键】time_change 字段只允许以下值之一:"清晨""上午""正午""下午""黄昏""夜晚""深夜",不要填写其他格式(如"30分钟""两小时后"等都是非法值)。如果本回合没有发生时间跳跃(例如休息、等待、长途旅行等),请设为 null(系统会自动推进一个时段)。只有当剧情需要跳跃多个时段时才设置此字段。
174
+ - 【关键】游戏内的货币单位统一为"金币",对应 gold_change 字段。严禁使用"银币""铜币""银两""钱币"等其他货币名称。任何财物/钱财类收获必须通过 gold_change 字段表达严禁将钱币放入 items_gained。举例:击败怪物掉落 3 金币 → gold_change: 3,绝对不要在 items_gained 中放入"铜币""银币""金币"
175
+ - 【关键 —— 装备规则】当玩家装备某个物品时,必须在 equip 字段中指定对应槽位和物品名称(如 "weapon": "小刀")。系统会自动将装备的物品从背包移到装备栏,并将替换下来的旧装备放回背包。因此装备操作时不要在 items_lost/items_gained 中重复处理该物品。合法槽位:weapon / armor / accessory / helmet / boots。卸下装备时将对应槽位设为 null。
176
  - 【关键】status_effects_added 中每个效果必须是一个对象,且必须包含以下字段:
177
  - "name": 中文效果名称(如"中毒""祝福""饱腹"),必须是中文,不要使用英文字段名
178
  - "description": 获得原因的简短说明(如"食用面包后精力充沛""被毒蛇咬伤")
 
203
  # - 选项应覆盖不同策略(如激进/保守/探索)
204
  # - 中等温度 (0.8) 增加文学创意
205
  # ------------------------------------------------------------
206
+ NARRATIVE_SYSTEM_PROMPT_TEMPLATE = """【最高优先级指令 ── 输出格式】
207
+ 你的输出必须严格遵守以下格式,这是最高优先级的要求,违反将导致系统崩溃:
208
+ 1. 先输出 ---STORY_TEXT--- 标记(独占一行)
209
+ 2. 然后直接写故事内容,不加任何前缀(禁止“好的”“以下是”等)
210
+ 3. 故事写完后输出 ---OPTIONS_JSON--- 标记(独占一行)
211
+ 4. 最后输出 JSON 格式的 3 个选项数组
212
+ 绝对不要省略这两个标记。
213
+
214
+ 你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写剧情文本。
215
 
216
  {world_state}
217
 
 
280
  # ------------------------------------------------------------
281
  # 开场叙事 Prompt
282
  # ------------------------------------------------------------
283
+ OPENING_NARRATIVE_PROMPT = """【��高优先级指令 ── 输出格式】
284
+ 你的输出必须严格遵守以下格式,这是最高优先级的要求,违反将导致系统崩溃:
285
+ 1. 先输出 ---STORY_TEXT--- 标记(独占一行)
286
+ 2. 然后直接写故事内容(200-400字),不加任何前缀(禁止“好的”“以下是”等)
287
+ 3. 故事写完后输出 ---OPTIONS_JSON--- 标记(独占一行)
288
+ 4. 最后输出 JSON 格式的 3 个选项数组
289
+ 绝对不要省略这两个标记。
290
+
291
+ 你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写开场白。
292
 
293
  {world_state}
294
 
 
313
  """
314
 
315
 
316
+ # ------------------------------------------------------------
317
+ # 合并式 Prompt:一次调用完成大纲 + 叙事 + 选项
318
+ #
319
+ # 设计思路:
320
+ # - 将两阶段生成合并为一次 API 调用,减少一半延迟
321
+ # - THINKING 区域供模型内部规划(不展示给玩家),确保一致性
322
+ # - STORY_TEXT 区域放在中间,支持流式输出(用户最先看到文字)
323
+ # - STATE_JSON 和 OPTIONS_JSON 放在末尾,供程序解析
324
+ # ------------------------------------------------------------
325
+ MERGED_SYSTEM_PROMPT_TEMPLATE = """【最高优先级指令 ── 输出格式】
326
+ 你的输出必须严格遵守以下分隔标记格式。这是最高优先级的要求,违反将导致系统崩溃:
327
+ - ---THINKING--- (规划区域)
328
+ - ---STORY_TEXT--- (故事文本,200-400字,不加任何前缀)
329
+ - ---STATE_JSON--- (状态变更 JSON)
330
+ - ---OPTIONS_JSON--- (3 个选项的 JSON 数组)
331
+ 每个区域必须以对应的 --- 标记开头(独占一行)。绝对不要省略任何标记。故事文本区域只放纯叙事文字,禁止混入 JSON。
332
+
333
+ 你是一个专业的 RPG 叙事引擎,兼具剧情规划与文学描写能力。
334
+
335
+ {world_state}
336
+
337
+ 【你的任务】
338
+ 根据玩家的行动意图,在一次输出中完成以下所有工作:
339
+ 1. 在 THINKING 标签内进行简短的剧情规划(不展示给玩家)
340
+ 2. 写一段 200-400 字的剧情描写
341
+ 3. 输出结构化的状态变更 JSON
342
+ 4. 生成 3 个后续选项
343
+
344
+ 【状态变更规则】
345
+ - 只填写确实发生变化的字段,未变化的设为 null/0/空或省略
346
+ - 所有数值变更必须精确,禁止模糊描述如"恢复一些"
347
+ - time_change 仅允许:"清晨""上午""正午""下午""黄昏""夜晚""深夜",无跳跃则 null
348
+ - 货币统一为"金币"(gold_change),严禁使用"铜币""银币""银两"等。任何钱财收获只通过 gold_change 表达,严禁将钱币放入 items_gained
349
+ - 装备规则:装备物品时用 equip 字段指定槽位和物品名(如 "weapon": "小刀"),系统自动处理背包⇌装备栏转移。装备时不要在 items_lost/items_gained 重复处理该物品。合法槽位:weapon/armor/accessory/helmet/boots。卸下装备设槽位为 null
350
+ - status_effects_added 中每个效果须含:name/description/effect_type/stat_modifiers/duration
351
+ - 数值不超合法范围(HP∈[0,max_hp],饱食度/士气/理智∈[0,100])
352
+ - new_location/weather_change/title_change/world_event 仅真正变化时填写,否则 null
353
+ - quest_updates.status 只允许 "active"/"completed"/"failed"
354
+ - 消耗品(药水、食物等一次性物品)使用后放入 items_lost;非消耗品(武器、工具等)使用后不放入 items_lost
355
+ - 仅重要事件才添加状态效果,普通日常行为不产生
356
+ - npc_changes 格式: {{"NPC名称": {{"attitude": "新态度", "relationship_change": 数值, "memory_add": "新记忆", "hp_change": 数值}}}}
357
+ - quest_updates 格式: {{"任务ID": {{"objectives_completed": ["完成的目标"], "status": "新状态"}}}}
358
+
359
+ 【叙事写作规则】
360
+ - 使用第二人称"你"叙述
361
+ - 文本开头必须直接回应玩家的行动/选择
362
+ - 文风简洁自然有画面感,像写小说而非诗歌
363
+ - 多用具体动作和对话,少用比喻修辞
364
+ - 禁用:"阳光洒下""微风拂过""空气中弥漫着""如同XX般"
365
+ - 严禁重复相似的动作描写(如"握紧拳头""深吸一口气"等不可每回合重复)
366
+ - NPC 首次出场需先描写外貌/特征,再自然引出名字
367
+ - 战斗场景要紧张刺激
368
+ - 货币统一称"金币"
369
+
370
+ 【选项规则】
371
+ - 恰好 3 个选项,覆盖不同策略方向(激进/谨慎/探索/社交等)
372
+ - 选项中的人物/物品/地点必须已在当前或之前剧情中出现过
373
+ - 获得新物品时至少一个选项涉及使用该物品
374
+ - 使用物品的选项中该物品必须在背包中
375
+
376
+ 【输出格式(严格遵守,按以下顺序输出)】
377
+
378
+ ---THINKING---
379
+ (简短剧情规划:事件类型、涉及NPC、关键状态变更概要,3-5句话即可)
380
+
381
+ ---STORY_TEXT---
382
+ (200-400 字剧情描写)
383
+
384
+ ---STATE_JSON---
385
+ {{
386
+ "event_summary": "一句话描述",
387
+ "event_type": "COMBAT/DIALOGUE/MOVE/ITEM/QUEST/TRADE/REST/DISCOVERY",
388
+ "involved_npcs": [],
389
+ "state_changes": {{
390
+ "hp_change": 0, "mp_change": 0, "gold_change": 0, "exp_change": 0,
391
+ "morale_change": 0, "sanity_change": 0, "hunger_change": 0, "karma_change": 0,
392
+ "new_location": null, "items_gained": [], "items_lost": [],
393
+ "skills_gained": [], "status_effects_added": [], "status_effects_removed": [],
394
+ "npc_changes": {{}}, "quest_updates": {{}},
395
+ "weather_change": null, "time_change": null, "global_flags_set": {{}},
396
+ "world_event": null, "equip": {{}}, "title_change": null
397
+ }},
398
+ "consequence_tags": [],
399
+ "is_reversible": true
400
+ }}
401
+
402
+ ---OPTIONS_JSON---
403
+ [
404
+ {{"id": 1, "text": "选项描述", "action_type": "动作类型(ATTACK/TALK/MOVE/EXPLORE/USE_ITEM等)"}},
405
+ {{"id": 2, "text": "选项描述", "action_type": "动作类型"}},
406
+ {{"id": 3, "text": "选项描述", "action_type": "动作类型"}}
407
+ ]
408
+ """
409
+
410
+
411
  class StoryEngine:
412
  """
413
  叙事引擎 —— 负责故事内容的生成
 
713
  "consistency_issues": [],
714
  }
715
 
716
+ @staticmethod
717
+ def _clean_story_text(story_text: str) -> str:
718
+ """
719
+ 清理故事文本中残留的 JSON 选项数组和格式标记。
720
+
721
+ LLM 有时会将选项 JSON 嵌入到故事文本中(尤其在标记之间),
722
+ 此方法移除这些 JSON 片段,仅保留纯叙事文本。
723
+ """
724
+ # 移除看起来像选项 JSON 数组的内容: [{"id": ...}, ...]
725
+ cleaned = re.sub(
726
+ r'\[\s*\{\s*"id"\s*:.*?\]',
727
+ '',
728
+ story_text,
729
+ flags=re.DOTALL,
730
+ )
731
+ # 移除残留的标记
732
+ for marker in ["---STORY_TEXT---", "---OPTIONS_JSON---", "---STATE_JSON---", "---THINKING---"]:
733
+ cleaned = cleaned.replace(marker, "")
734
+ # 移除可能因清理产生的多余空行(保留最多一个空行)
735
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
736
+ return cleaned.strip()
737
+
738
  def _parse_story_response(self, raw_text: str) -> tuple[str, list[dict]]:
739
  """
740
  解析 LLM 返回的故事响应,分离文本和选项。
741
 
742
+ 采用 **暴力提取策略**
743
+ 1. 优先尝试标准标记格式
744
+ 2. 如都失败,默认将整段文本视为故事文本
745
+ 3. 从末尾向前查找 JSON 块 ([...] 或 {...}),若找到则切除并解析为选项
746
+ 4. 找不到任何 JSON 则整段当故事 + 硬性默认选项
 
 
747
  """
748
+ # ★ 调试日志
749
+ logger.debug(f"原始 API 返回内容: {raw_text}")
750
+
751
  story_text = ""
752
  options = []
753
 
754
+ if not raw_text or not raw_text.strip():
755
+ logger.warning("API 返回内容为空")
756
+ return "你环顾四周,思考着接下来该做什么...", self._generate_default_options()
757
+
758
+ # 标准化标记格式
759
+ normalized = _normalize_markers(raw_text)
760
+
761
+ # ========== 策略 1:标准标记格式 ==========
762
+ if "---STORY_TEXT---" in normalized and "---OPTIONS_JSON---" in normalized:
763
+ story_start = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
764
+ options_start = normalized.index("---OPTIONS_JSON---")
765
+ story_part = normalized[story_start:options_start].strip()
766
+ options_part = normalized[options_start + len("---OPTIONS_JSON---"):].strip()
767
 
768
  story_text = story_part
769
  parsed_options = extract_json_from_text(options_part)
770
+ if isinstance(parsed_options, list) and len(parsed_options) > 0:
771
  options = parsed_options
772
+ logger.info("标准格式解析成功")
773
+ # ========== 策略 1b只有 STORY_TEXT 没有 OPTIONS_JSON ==========
774
+ elif "---STORY_TEXT---" in normalized:
775
+ logger.warning(" STORY_TEXT 标记但缺少 OPTIONS_JSON,暴力提取")
776
+ story_start = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
777
+ remaining = normalized[story_start:].strip()
778
+ # 从 remaining 末尾查找 JSON
779
+ story_text, options = self._brute_force_extract(remaining)
780
+
781
+ # ========== 策略 2:完全没有标记 → 暴力提取 ==========
782
+ if not story_text.strip():
783
+ logger.warning("响应格式不标准,使用暴力提取策略")
784
+ story_text, options = self._brute_force_extract(normalized)
785
+
786
+ # 清理 story_text 中可能残留的 JSON 和标记
787
+ story_text = self._clean_story_text(story_text)
788
+
789
+ # 移除常见的 AI 前缀
790
+ story_text = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', story_text, flags=re.MULTILINE)
791
+
792
+ # 如果 story_text 仍为空,使用兜底文本
793
+ if not story_text.strip():
794
+ logger.warning("解析后 story_text 为空,使用兜底文本")
795
+ story_text = "你环顾四周,思考着接下来该做什么..."
796
 
797
  # 确保至少有默认选项
798
  if not options:
799
+ logger.info("未提取到有效选项,使用默认选项")
800
  options = self._generate_default_options()
801
 
802
+ # 确保恰好 3 个选项
803
+ options = self._ensure_three_options(options)
804
+
805
+ return story_text, options
806
+
807
+ def _brute_force_extract(self, text: str) -> tuple[str, list[dict]]:
808
+ """
809
+ 暴力提取策略:默认整段文本都是故事,从末尾向前查找 JSON 块并切除。
810
+
811
+ Args:
812
+ text: 待解析的文本
813
+
814
+ Returns:
815
+ (story_text, options_list)
816
+ """
817
+ options = []
818
+ story_text = text
819
+
820
+ # 第一步:尝试从末尾向前找 JSON 数组 [...]
821
+ json_array_matches = list(re.finditer(r'\[\s*\{.*?\}\s*\]', text, re.DOTALL))
822
+ if json_array_matches:
823
+ last_match = json_array_matches[-1]
824
+ try:
825
+ parsed = extract_json_from_text(last_match.group())
826
+ if isinstance(parsed, list) and len(parsed) > 0:
827
+ # 检查是否看起来像选项(至少一个元素有 text 或 id 字段)
828
+ if any(isinstance(item, dict) and ("text" in item or "id" in item) for item in parsed):
829
+ options = parsed
830
+ story_text = text[:last_match.start()].strip()
831
+ logger.info(f"暴力提取:从末尾找到 JSON 数组,提取了 {len(options)} 个选项")
832
+ except Exception:
833
+ pass
834
+
835
+ # 第二步:如果没找到数组,尝试找独立的 JSON 对象 {...}
836
+ if not options:
837
+ json_obj_matches = list(re.finditer(r'\{[^{}]*"(?:text|id|action_type)"[^{}]*\}', text, re.DOTALL))
838
+ if json_obj_matches and len(json_obj_matches) >= 2:
839
+ first_match_start = json_obj_matches[0].start()
840
+ last_match_end = json_obj_matches[-1].end()
841
+ json_block = text[first_match_start:last_match_end]
842
+ wrapped = "[" + json_block + "]"
843
+ try:
844
+ parsed = extract_json_from_text(wrapped)
845
+ if isinstance(parsed, list) and len(parsed) > 0:
846
+ options = parsed
847
+ story_text = text[:first_match_start].strip()
848
+ logger.info(f"暴力提取:通过独立 JSON 对象拼合,提取了 {len(options)} 个选项")
849
+ except Exception:
850
+ pass
851
+
852
+ # 第三步:完全没有 JSON 影子 → 整段当故事
853
+ if not options:
854
+ logger.info("暴力提取:未发现任何 JSON,整段文本作为故事,使用默认选项")
855
+ story_text = text
856
+
857
  return story_text, options
858
 
859
  def _generate_default_options(self) -> list[dict]:
 
886
 
887
  return default_options[:3]
888
 
889
+ def _ensure_three_options(self, options: list[dict]) -> list[dict]:
890
+ """
891
+ 确保恰好有 3 个选项,不足时用默认选项补齐。
892
+
893
+ LLM 有时只生成 2 个甚至 1 个选项,此方法确保 UI 始终显示 3 个按钮。
894
+ """
895
+ if len(options) >= 3:
896
+ options = options[:3]
897
+ else:
898
+ defaults = self._generate_default_options()
899
+ for d in defaults:
900
+ if len(options) >= 3:
901
+ break
902
+ # 避免重复选项
903
+ if not any(o.get("text") == d["text"] for o in options):
904
+ options.append(d)
905
+ # 兜底:如果仍不足 3 个,补充通用选项
906
+ while len(options) < 3:
907
+ options.append({
908
+ "id": len(options) + 1,
909
+ "text": "继续探索",
910
+ "action_type": "EXPLORE",
911
+ })
912
+ # 重新编号
913
+ for i, opt in enumerate(options[:3], 1):
914
+ opt["id"] = i
915
+ return options[:3]
916
+
917
  def _fallback_response(self, player_intent: dict, tick_log: list[str] | None = None) -> dict:
918
  """
919
  降级响应:当大纲生成完全失败时,提供基本响应。
 
1065
  if is_valid:
1066
  validated.append(opt)
1067
 
1068
+ # 确保恰好 3 个选项
1069
+ return self._ensure_three_options(validated)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1070
 
1071
  def process_option_selection(self, option: dict) -> dict:
1072
  """
 
1087
  "raw_input": option.get("text", ""),
1088
  }
1089
  return self.generate_story(intent)
1090
+
1091
+ # ============================================================
1092
+ # 流式输出 + 合并式生成(核心优化)
1093
+ # ============================================================
1094
+
1095
+ def generate_opening_stream(self):
1096
+ """
1097
+ 流式生成游戏开场叙事。
1098
+
1099
+ ★ 重要设计:流式循环中仅做文本展示(story_chunk),
1100
+ 选项解析逻辑严格在整个数据流结束后才执行一次。
1101
+
1102
+ Yields:
1103
+ {"type": "story_chunk", "text": "累积的故事文本"} — 流式文本更新
1104
+ {"type": "final", ...} — 最终完整结果(包含必定 3 个选项)
1105
+ """
1106
+ logger.info("[流式] 生成游戏开场叙事...")
1107
+
1108
+ prompt = OPENING_NARRATIVE_PROMPT.format(
1109
+ world_state=self.game_state.to_prompt(),
1110
+ player_name=self.game_state.player.name,
1111
+ )
1112
+
1113
+ messages = [
1114
+ {"role": "system", "content": prompt},
1115
+ {"role": "user", "content": "请开始讲述故事的开场。"},
1116
+ ]
1117
+
1118
+ full_text = ""
1119
+ story_started = False
1120
+ story_ended = False
1121
+
1122
+ try:
1123
+ for chunk in call_qwen_stream(messages, model=self.model, temperature=0.9, max_tokens=2000):
1124
+ full_text += chunk
1125
+
1126
+ # ★ 流式循环中只做展示用途的标记检测,绝不在此处解析选项
1127
+ normalized = _normalize_markers(full_text)
1128
+
1129
+ if not story_started:
1130
+ if "---STORY_TEXT---" in normalized:
1131
+ story_started = True
1132
+ idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
1133
+ current_story = normalized[idx:]
1134
+ if "---OPTIONS_JSON---" in current_story:
1135
+ story_ended = True
1136
+ current_story = current_story[:current_story.index("---OPTIONS_JSON---")]
1137
+ display_text = self._clean_story_text(current_story)
1138
+ if display_text.strip():
1139
+ yield {"type": "story_chunk", "text": display_text.strip()}
1140
+ elif not story_ended:
1141
+ idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
1142
+ current_story = normalized[idx:]
1143
+ if "---OPTIONS_JSON---" in current_story:
1144
+ story_ended = True
1145
+ current_story = current_story[:current_story.index("---OPTIONS_JSON---")]
1146
+ display_text = self._clean_story_text(current_story)
1147
+ if display_text.strip():
1148
+ yield {"type": "story_chunk", "text": display_text.strip()}
1149
+
1150
+ except Exception as e:
1151
+ logger.error(f"流式开场生成失败: {e},降级为非流式")
1152
+ try:
1153
+ result = self.generate_opening()
1154
+ # 降级结果也强制保证 3 个选项
1155
+ result["options"] = self._ensure_three_options(result.get("options", []))
1156
+ yield {"type": "final", **result}
1157
+ except Exception:
1158
+ yield {
1159
+ "type": "final",
1160
+ "story_text": "你踏上了一段新的旅程...",
1161
+ "options": self._generate_default_options(),
1162
+ "state_changes": {},
1163
+ "change_log": [],
1164
+ }
1165
+ return
1166
+
1167
+ # ★ 如果流式阶段未检测到标记但有累积文本,先 yield 给 UI 显示
1168
+ if not story_started and full_text.strip():
1169
+ logger.warning("流式阶段未检测到 STORY_TEXT 标记,直接使用累积文本")
1170
+ # 清理后先 yield 给 UI,确保用户能看到文本
1171
+ display = self._clean_story_text(full_text.strip())
1172
+ display = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', display, flags=re.MULTILINE)
1173
+ if display.strip():
1174
+ yield {"type": "story_chunk", "text": display.strip()}
1175
+
1176
+ # ★ 核心:只在数据流完全结束后,用完整 full_text 解析(暴力提取策略)
1177
+ story_text, options = self._parse_story_response(full_text)
1178
+
1179
+ # ★ 双重保险:确保恰好 3 个选项
1180
+ options = self._ensure_three_options(options)
1181
+
1182
+ logger.info(f"[流式开场] 最终 story_text 长度={len(story_text)}, 选项数={len(options)}")
1183
+
1184
+ yield {
1185
+ "type": "final",
1186
+ "story_text": story_text,
1187
+ "options": options,
1188
+ "state_changes": {},
1189
+ "change_log": [],
1190
+ }
1191
+
1192
+ def generate_story_stream(self, player_intent: dict):
1193
+ """
1194
+ 流式生成故事响应(合并大纲+叙事为一次 API 调用)。
1195
+
1196
+ 使用 MERGED_SYSTEM_PROMPT_TEMPLATE,一次调用完成:
1197
+ - 内部规划(THINKING 区域,不展示)
1198
+ - 故事文本(STORY_TEXT 区域,流式展示)
1199
+ - 状态变更(STATE_JSON 区域,程序解析)
1200
+ - 选项(OPTIONS_JSON 区域,程序解析)
1201
+
1202
+ Yields:
1203
+ {"type": "story_chunk", "text": "累积的故事文本"}
1204
+ {"type": "final", "story_text": ..., "options": ..., ...}
1205
+ """
1206
+ logger.info(f"[流式/合并] 生成故事响应,玩家意图: {player_intent}")
1207
+
1208
+ # 推进时间
1209
+ tick_log = self.game_state.tick_time()
1210
+
1211
+ # 构建合并 Prompt
1212
+ system_prompt = MERGED_SYSTEM_PROMPT_TEMPLATE.format(
1213
+ world_state=self.game_state.to_prompt(),
1214
+ )
1215
+
1216
+ user_message = (
1217
+ f"玩家行动: {player_intent.get('raw_input', '未知行动')}\n"
1218
+ f"解析意图: {player_intent.get('intent', 'UNKNOWN')}\n"
1219
+ f"目标: {player_intent.get('target', '无')}\n"
1220
+ f"细节: {player_intent.get('details', '无')}"
1221
+ )
1222
+
1223
+ messages = [
1224
+ {"role": "system", "content": system_prompt},
1225
+ {"role": "user", "content": user_message},
1226
+ ]
1227
+
1228
+ full_text = ""
1229
+ story_started = False
1230
+ story_ended = False
1231
+
1232
+ try:
1233
+ for chunk in call_qwen_stream(messages, model=self.model, temperature=0.7, max_tokens=3000):
1234
+ full_text += chunk
1235
+
1236
+ # 使用标准化标记检测,处理 LLM 输出的常见变体格式
1237
+ normalized = _normalize_markers(full_text)
1238
+
1239
+ # 跳过 THINKING 区域,从 STORY_TEXT 开始流式输出
1240
+ if not story_started:
1241
+ if "---STORY_TEXT---" in normalized:
1242
+ story_started = True
1243
+ idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
1244
+ current_story = normalized[idx:]
1245
+ # 检查是否已经到了 STATE_JSON
1246
+ if "---STATE_JSON---" in current_story:
1247
+ story_ended = True
1248
+ current_story = current_story[:current_story.index("---STATE_JSON---")]
1249
+ display_text = self._clean_story_text(current_story)
1250
+ if display_text.strip():
1251
+ yield {"type": "story_chunk", "text": display_text.strip()}
1252
+ elif not story_ended:
1253
+ idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
1254
+ current_story = normalized[idx:]
1255
+ if "---STATE_JSON---" in current_story:
1256
+ story_ended = True
1257
+ current_story = current_story[:current_story.index("---STATE_JSON---")]
1258
+ display_text = self._clean_story_text(current_story)
1259
+ if display_text.strip():
1260
+ yield {"type": "story_chunk", "text": display_text.strip()}
1261
+
1262
+ except Exception as e:
1263
+ logger.error(f"流式合并生成失败: {e},降级为非流式两阶段")
1264
+ try:
1265
+ result = self.generate_story(player_intent)
1266
+ # 降级结果也强制保证 3 个选项
1267
+ result["options"] = self._ensure_three_options(result.get("options", []))
1268
+ yield {"type": "final", **result}
1269
+ except Exception:
1270
+ fallback = self._fallback_response(player_intent, tick_log)
1271
+ fallback["options"] = self._ensure_three_options(fallback.get("options", []))
1272
+ yield {"type": "final", **fallback}
1273
+ return
1274
+
1275
+ # ★ 如果流式阶段未检测到标记但有累积文本,先 yield 给 UI 显示
1276
+ if not story_started and full_text.strip():
1277
+ logger.warning("[流式合并] 未检测到 STORY_TEXT 标记,直接使用累积文本")
1278
+ display = self._clean_story_text(full_text.strip())
1279
+ display = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', display, flags=re.MULTILINE)
1280
+ if display.strip():
1281
+ yield {"type": "story_chunk", "text": display.strip()}
1282
+
1283
+ # 解析合并响应
1284
+ outline, story_text, options = self._parse_merged_response(full_text)
1285
+
1286
+ if outline is None:
1287
+ # ★ outline 为空不代表 story_text 也为空!
1288
+ # 如果 story_text 已成功提取,继续走正常流程而非丢弃
1289
+ if story_text and story_text.strip():
1290
+ logger.warning("大纲(STATE_JSON)解析失败,但故事文本已提取,跳过状态更新继续")
1291
+ options = self._ensure_three_options(options)
1292
+ yield {
1293
+ "type": "final",
1294
+ "story_text": story_text,
1295
+ "options": options,
1296
+ "state_changes": {},
1297
+ "change_log": tick_log + ["(系统提示:本回合状态解析失败,未更新状态)"],
1298
+ "outline": None,
1299
+ "consistency_issues": [],
1300
+ }
1301
+ return
1302
+ else:
1303
+ logger.error("合并响应解析完全失败,使用降级")
1304
+ fallback = self._fallback_response(player_intent, tick_log)
1305
+ yield {"type": "final", **fallback}
1306
+ return
1307
+
1308
+ # 处理时间冲突
1309
+ state_changes = outline.get("state_changes", {})
1310
+ if state_changes.get("time_change"):
1311
+ tick_log = [line for line in tick_log if not line.startswith("时间流逝:")]
1312
+
1313
+ # 一致性检查
1314
+ consistency_issues = self.game_state.check_consistency(state_changes)
1315
+
1316
+ if consistency_issues:
1317
+ logger.warning(f"发现一致性问题: {consistency_issues}")
1318
+
1319
+ # 清理状态变更
1320
+ event_type = outline.get("event_type", "")
1321
+ state_changes, sanitize_warnings = self._sanitize_state_changes(state_changes, event_type)
1322
+
1323
+ # 应用状态变更
1324
+ change_log = self.game_state.apply_changes(state_changes)
1325
+
1326
+ # 校验状态合法性
1327
+ is_valid, validation_issues = self.game_state.validate()
1328
+
1329
+ # 记录事件
1330
+ self.game_state.log_event(
1331
+ event_type=outline.get("event_type", "UNKNOWN"),
1332
+ description=outline.get("event_summary", ""),
1333
+ player_action=player_intent.get("raw_input", ""),
1334
+ involved_npcs=outline.get("involved_npcs", []),
1335
+ state_changes=state_changes,
1336
+ consequence_tags=outline.get("consequence_tags", []),
1337
+ is_reversible=outline.get("is_reversible", True),
1338
+ )
1339
+
1340
+ # 检查游戏结束
1341
+ if self.game_state.is_game_over():
1342
+ death_result = self._generate_death_narrative()
1343
+ yield {"type": "final", **death_result}
1344
+ return
1345
+
1346
+ # 验证选项
1347
+ options = self._validate_options(options)
1348
+
1349
+ # ★ 双重保险:强制确保恰好 3 个选项
1350
+ options = self._ensure_three_options(options)
1351
+
1352
+ # 合并日志
1353
+ merged_log = _merge_change_logs(tick_log, change_log + validation_issues)
1354
+
1355
+ yield {
1356
+ "type": "final",
1357
+ "story_text": story_text,
1358
+ "options": options,
1359
+ "state_changes": state_changes,
1360
+ "change_log": merged_log,
1361
+ "outline": outline,
1362
+ "consistency_issues": consistency_issues,
1363
+ }
1364
+
1365
+ def process_option_selection_stream(self, option: dict):
1366
+ """
1367
+ 流式处理玩家点击选项。
1368
+ 将选项转化为意图格式,然后调用 generate_story_stream。
1369
+ """
1370
+ intent = {
1371
+ "intent": option.get("action_type", "EXPLORE"),
1372
+ "target": None,
1373
+ "details": option.get("text", ""),
1374
+ "raw_input": option.get("text", ""),
1375
+ }
1376
+ yield from self.generate_story_stream(intent)
1377
+
1378
+ def _parse_merged_response(self, raw_text: str) -> tuple:
1379
+ """
1380
+ 解析合并格式的响应(THINKING + STORY_TEXT + STATE_JSON + OPTIONS_JSON)。
1381
+
1382
+ Returns:
1383
+ (outline_dict, story_text, options_list)
1384
+ outline_dict 可能为 None(解析失败时)
1385
+ """
1386
+ # ★ 调试日志:记录原始 API 返回内容
1387
+ logger.debug(f"原始 API 返回内容(合并模式): {raw_text}")
1388
+
1389
+ outline = None
1390
+ story_text = ""
1391
+ options = []
1392
+
1393
+ # 标准化标记格式
1394
+ normalized = _normalize_markers(raw_text)
1395
+
1396
+ # 解析 STORY_TEXT
1397
+ if "---STORY_TEXT---" in normalized:
1398
+ story_start = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
1399
+ # STORY_TEXT 的结尾可能是 STATE_JSON 或 OPTIONS_JSON
1400
+ story_end = len(normalized)
1401
+ if "---STATE_JSON---" in normalized:
1402
+ story_end = normalized.index("---STATE_JSON---")
1403
+ elif "---OPTIONS_JSON---" in normalized:
1404
+ story_end = normalized.index("---OPTIONS_JSON---")
1405
+ story_text = normalized[story_start:story_end].strip()
1406
+
1407
+ # 解析 STATE_JSON
1408
+ if "---STATE_JSON---" in normalized:
1409
+ state_start = normalized.index("---STATE_JSON---") + len("---STATE_JSON---")
1410
+ state_end = len(normalized)
1411
+ if "---OPTIONS_JSON---" in normalized:
1412
+ state_end = normalized.index("---OPTIONS_JSON---")
1413
+ state_part = normalized[state_start:state_end].strip()
1414
+ outline = extract_json_from_text(state_part)
1415
+
1416
+ # 解析 OPTIONS_JSON
1417
+ if "---OPTIONS_JSON---" in normalized:
1418
+ options_start = normalized.index("---OPTIONS_JSON---") + len("---OPTIONS_JSON---")
1419
+ options_part = normalized[options_start:].strip()
1420
+ parsed = extract_json_from_text(options_part)
1421
+ if isinstance(parsed, list):
1422
+ options = parsed
1423
+
1424
+ if not options:
1425
+ options = self._generate_default_options()
1426
+
1427
+ # 确保恰好 3 个选项
1428
+ options = self._ensure_three_options(options)
1429
+
1430
+ if not story_text and outline is None:
1431
+ # 完全解析失败 → 使用暴力提取策略
1432
+ logger.warning("合并格式解析完全失败,使用暴力提取策略")
1433
+ # 先移除 THINKING 区域
1434
+ cleaned = re.sub(r'---THINKING---.*?(?=---[A-Z]|$)', '', raw_text, flags=re.DOTALL | re.IGNORECASE).strip()
1435
+ if not cleaned:
1436
+ cleaned = raw_text
1437
+ story_text, options_fallback = self._brute_force_extract(cleaned)
1438
+ if options_fallback:
1439
+ options = self._ensure_three_options(options_fallback)
1440
+ elif not story_text.strip() and raw_text.strip():
1441
+ # story_text 为空但有原始文本 → 暴力提取
1442
+ logger.warning("story_text 为空,使用暴力提取")
1443
+ cleaned = re.sub(r'---THINKING---.*?(?=---[A-Z]|$)', '', raw_text, flags=re.DOTALL | re.IGNORECASE).strip()
1444
+ if not cleaned:
1445
+ cleaned = raw_text
1446
+ story_text, options_fallback = self._brute_force_extract(cleaned)
1447
+ if options_fallback:
1448
+ options = self._ensure_three_options(options_fallback)
1449
+
1450
+ # 清理 story_text 中可能残留的 JSON 和标记
1451
+ story_text = self._clean_story_text(story_text)
1452
+
1453
+ # 移除常见 AI 前缀
1454
+ story_text = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', story_text, flags=re.MULTILINE)
1455
+
1456
+ # 确保 story_text 不为空
1457
+ if not story_text.strip():
1458
+ story_text = "你环顾四周,思考着接下来该做什么..."
1459
+
1460
+ # 确保选项
1461
+ if not options:
1462
+ options = self._generate_default_options()
1463
+ options = self._ensure_three_options(options)
1464
+
1465
+ return outline, story_text, options
utils.py CHANGED
@@ -63,8 +63,8 @@ def get_client() -> OpenAI:
63
  # ============================================================
64
  # 默认模型配置
65
  # ============================================================
66
- # 推荐使用 qwen-plus 平衡速度和效果
67
- DEFAULT_MODEL: str = "qwen-plus"
68
 
69
 
70
  # ============================================================
@@ -132,6 +132,45 @@ def call_qwen(
132
  )
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  # ============================================================
136
  # JSON 安全解析工具
137
  # ============================================================
 
63
  # ============================================================
64
  # 默认模型配置
65
  # ============================================================
66
+ # 使用 qwen-turbo 以获得最快的响应速度
67
+ DEFAULT_MODEL: str = "qwen-turbo"
68
 
69
 
70
  # ============================================================
 
132
  )
133
 
134
 
135
+ def call_qwen_stream(
136
+ messages: list[dict[str, str]],
137
+ model: str = DEFAULT_MODEL,
138
+ temperature: float = 0.8,
139
+ max_tokens: int = 2000,
140
+ ):
141
+ """
142
+ 调用 Qwen API 的流式版本,逐块 yield 文本内容。
143
+
144
+ 使用 stream=True,让用户在 AI 生成过程中就能看到文字逐步出现,
145
+ 大幅改善感知延迟。
146
+
147
+ Args:
148
+ messages: OpenAI 格式的消息列表
149
+ model: 模型名称
150
+ temperature: 生成温度
151
+ max_tokens: 最大生成 token 数
152
+
153
+ Yields:
154
+ 每次生成的文本片段(str)
155
+ """
156
+ client = get_client()
157
+ logger.info(f"调用 Qwen 流式 API,模型: {model}")
158
+ try:
159
+ response = client.chat.completions.create(
160
+ model=model,
161
+ messages=messages,
162
+ temperature=temperature,
163
+ max_tokens=max_tokens,
164
+ stream=True,
165
+ )
166
+ for chunk in response:
167
+ if chunk.choices and chunk.choices[0].delta.content:
168
+ yield chunk.choices[0].delta.content
169
+ except Exception as e:
170
+ logger.error(f"流式 API 调用失败: {e}")
171
+ raise
172
+
173
+
174
  # ============================================================
175
  # JSON 安全解析工具
176
  # ============================================================