From 3e404d2650421242d7638cf3d0464302ae46f0ee Mon Sep 17 00:00:00 2001 From: wkbin Date: Thu, 14 May 2026 12:37:48 +0800 Subject: [PATCH 01/11] refactor: unify dialogue session runtime state --- docs/session-state-v1.md | 110 +++++ src/core/session_store.py | 8 +- src/web/chat/helpers.py | 86 +++- src/web/chat/service.py | 687 +++++++++++++++++++++++++--- src/web/service_facades/dialogue.py | 36 +- tests/test_session_store.py | 10 +- tests/test_web_app.py | 138 ++++-- 7 files changed, 961 insertions(+), 114 deletions(-) create mode 100644 docs/session-state-v1.md diff --git a/docs/session-state-v1.md b/docs/session-state-v1.md new file mode 100644 index 0000000..a4a20cf --- /dev/null +++ b/docs/session-state-v1.md @@ -0,0 +1,110 @@ +# Session State V1 + +## Goal + +This branch treats dialogue session state as a first-class runtime model instead of a loose bag of side effects. + +We intentionally optimize for a clean canonical schema rather than backward compatibility. + +## Canonical State + +Each dialogue session owns one canonical `state` object: + +```json +{ + "version": 1, + "scene": { + "location": "", + "time_hint": "", + "atmosphere_summary": "", + "progression_note": "", + "updated_at": "" + }, + "presence": { + "present_participants": [], + "offstage_participants": [], + "updated_at": "" + }, + "progression": { + "should_offer_scene_shift": false, + "scene_shift_reason": "", + "turns_in_current_scene": 0, + "beat_maturity": 0, + "world_tension_summary": "", + "updated_at": "" + }, + "relations": { + "matrix": {}, + "delta": {} + }, + "characters": { + "snapshots": {} + }, + "signals": { + "recent": [], + "by_type": {}, + "updated_at": "" + }, + "memory": { + "summary": {} + } +} +``` + +## Rules + +1. `state` is the only source of truth for session runtime evolution. +2. API payloads may still project convenience views like `scene_progress` or `relation_delta`, but those are derived views, not primary storage. +3. Session payloads may expose derived helpers like `runtime_state_overview` for UI rendering, but these are read-only projections from canonical `state`. +4. `runtime_state_overview` should stay presentation-friendly: short labels, trimmed text, and stable ordering for characters / relations / events. +5. Scene flow is split into three concerns: + - `scene`: where/when/what tone the current beat has + - `presence`: who is currently onstage or offstage + - `progression`: whether the beat is mature enough to shift scenes +6. Relationship updates are split into: + - `relations.matrix`: baseline merged relation graph for session participants + - `relations.delta`: session-local drift caused by this conversation +7. Character-local runtime drift belongs in `characters.snapshots`. +8. Small event cues, transitions, exits, and atmosphere shifts belong in `signals`. +9. Compression summaries belong in `memory.summary`. + +## Implementation Checklist + +### Slice 1: Canonical State Foundation + +- [x] Define the canonical session-state schema +- [x] Centralize session-state creation and normalization +- [x] Project derived `scene_progress` from canonical state +- [x] Project derived `relation_delta`, `character_snapshots`, and `event_signals` +- [x] Move session-store readers to canonical state paths + +### Slice 2: Progression Engine + +- [x] Split time, location, atmosphere, and onstage/offstage decisions into dedicated state updaters +- [x] Track scene maturity explicitly in `progression.beat_maturity` +- [x] Let narration, exits, and returns update canonical presence state directly + +### Slice 3: Session Snapshots + +- [x] Expand character snapshots into stable per-character runtime cards +- [x] Expand relation deltas into stable per-pair interaction drift +- [x] Add explicit session-level world tension / atmosphere summary + +### Slice 4: Prompt Integration + +- [x] Feed canonical state into turn payloads +- [x] Feed canonical state into suggestion payloads +- [x] Feed canonical state into scene-progress generation prompts +- [x] Trim prompt payloads using canonical active-state priority + +### Slice 5: UI Integration + +- [x] Surface canonical presence/time/progression hints in the chat UI +- [x] Surface natural next-scene hints from `progression` +- [x] Surface per-character session drift from `characters.snapshots` + +## Non-Goals + +- Preserving every old session-state shape on disk +- Layering more compatibility shims for low-value legacy paths +- Keeping duplicated state across multiple top-level session fields diff --git a/src/core/session_store.py b/src/core/session_store.py index 4491bc7..834b893 100644 --- a/src/core/session_store.py +++ b/src/core/session_store.py @@ -91,12 +91,12 @@ def compress_context( to_archive = history[:-recent_turns] keep = history[-recent_turns:] state = session.setdefault("state", {}) - memory_summary = dict(state.get("memory_summary", {}) or {}) + memory_summary = dict(dict(state.get("memory", {}) or {}).get("summary", {}) or {}) previous_summary = _normalize_text(memory_summary.get("summary", "")) compressed = self._build_memory_summary(previous_summary, to_archive, summary_limit) key_points = self._extract_key_points(to_archive, limit=8) - state["memory_summary"] = { + state.setdefault("memory", {})["summary"] = { "summary": compressed, "key_points": key_points, "compressed_turns": len(to_archive), @@ -199,8 +199,8 @@ def save_relation_snapshot(self, session: Dict[str, Any]) -> None: "session_id": session_id, "novel_id": session.get("novel_id"), "updated_at": session.get("updated_at"), - "relation_matrix": session.get("state", {}).get("relation_matrix", {}), - "relation_delta": session.get("state", {}).get("relation_delta", {}), + "relation_matrix": dict(dict(session.get("state", {}).get("relations", {}) or {}).get("matrix", {}) or {}), + "relation_delta": dict(dict(session.get("state", {}).get("relations", {}) or {}).get("delta", {}) or {}), } save_markdown_data( self._relation_snapshot_path(session_id), diff --git a/src/web/chat/helpers.py b/src/web/chat/helpers.py index 198c98e..c47b3ca 100644 --- a/src/web/chat/helpers.py +++ b/src/web/chat/helpers.py @@ -7,6 +7,55 @@ from src.core.exceptions import LLMRequestError +def _session_state(session: dict[str, Any]) -> dict[str, Any]: + return dict(session.get("state", {}) or {}) + + +def _canonical_scene_progress(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + scene = dict(state.get("scene", {}) or {}) + presence = dict(state.get("presence", {}) or {}) + progression = dict(state.get("progression", {}) or {}) + derived = { + "present_participants": list(presence.get("present_participants", []) or []), + "offstage_participants": list(presence.get("offstage_participants", []) or []), + "time_hint": str(scene.get("time_hint", "")).strip(), + "location": str(scene.get("location", "")).strip(), + "atmosphere_summary": str(scene.get("atmosphere_summary", "")).strip(), + "progression_note": str(scene.get("progression_note", "")).strip(), + "should_offer_scene_shift": bool(progression.get("should_offer_scene_shift", False)), + "scene_shift_reason": str(progression.get("scene_shift_reason", "")).strip(), + "turns_in_current_scene": int(progression.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(progression.get("beat_maturity", 0) or 0), + "world_tension_summary": str(progression.get("world_tension_summary", "")).strip(), + "updated_at": ( + str(progression.get("updated_at", "")).strip() + or str(presence.get("updated_at", "")).strip() + or str(scene.get("updated_at", "")).strip() + ), + } + merged = dict(derived) + merged.update(dict(session.get("scene_progress", {}) or {})) + return {key: value for key, value in merged.items() if value not in ("", [], False, 0, None)} + + +def _canonical_relation_delta(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + relations = dict(state.get("relations", {}) or {}) + return dict(session.get("relation_delta", {}) or relations.get("delta", {}) or {}) + + +def _canonical_character_snapshots(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + characters = dict(state.get("characters", {}) or {}) + return dict(session.get("character_snapshots", {}) or characters.get("snapshots", {}) or {}) + + +def _canonical_event_signals(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + return dict(session.get("event_signals", {}) or state.get("signals", {}) or {}) + + def build_dialogue_opening_message(session: dict[str, Any]) -> str: mode = str(session.get("mode", "observe")).strip() or "observe" participants = [str(item).strip() for item in session.get("participants", []) if str(item).strip()] @@ -239,7 +288,11 @@ def _compact_memory_context(memory_context: dict[str, Any]) -> dict[str, Any]: "archived_summary": compact_archived, "retrieved_memories": compact_hits, "scene_progress": { - inner_key: _trim_text(str(inner_value).strip(), 100) + inner_key: ( + list(inner_value)[:6] + if isinstance(inner_value, list) + else inner_value + ) for inner_key, inner_value in scene_progress.items() if _has_meaningful_value(inner_value) }, @@ -457,8 +510,8 @@ def build_dialogue_scene_progress_messages(session: dict[str, Any]) -> list[dict "scene_card": dict(session.get("session_card", {}).get("scene_card", {}) or session.get("scene_card", {}) or {}), "session_memory_summary": dict(session.get("session_memory_summary", {}) or {}), "recent_transcript": recent, - "current_scene_progress": dict(session.get("scene_progress", {}) or {}), - "event_signals": dict(session.get("event_signals", {}) or session.get("state", {}).get("event_signals", {}) or {}), + "current_scene_progress": _canonical_scene_progress(session), + "event_signals": _canonical_event_signals(session), } system_prompt = "\n".join( [ @@ -467,10 +520,13 @@ def build_dialogue_scene_progress_messages(session: dict[str, Any]) -> list[dict "offstage_participants 里的人默认不应继续直接开口,除非最近文本明确写到他们回来、进门、现身、重新加入。", "如果最近内容已经从白天聊到傍晚、夜里、深夜等,time_hint 要跟着更新,而不是一直停在原时间。", "如果几个人已经离开原场所进入更私密的新地点,其他未同去角色不要继续被视作同场。", + "atmosphere_summary 用一句很短的话概括当前氛围,比如“安静下来”“暧昧发僵”“雨夜压下来”。", + "beat_maturity 用 0-100 的整数表示这一拍推进到什么程度:刚起势偏低,已经聊出完整一拍则更高。", + "world_tension_summary 用一句话概括当前这局最该继续带着走的张力、冲突或悬念。", "event_signals 里如果出现 scene_transition / cast_enter / cast_exit / atmosphere_shift / time_change / environment_change / beat_complete,要把它们纳入判断。", "should_offer_scene_shift 只在这一幕已经聊出明显一拍、适合自然转场时返回 true。", "只返回 JSON 对象,不要解释。", - "格式:{\"present_participants\":[],\"offstage_participants\":[],\"time_hint\":\"\",\"location\":\"\",\"progression_note\":\"\",\"should_offer_scene_shift\":false,\"scene_shift_reason\":\"\"}", + "格式:{\"present_participants\":[],\"offstage_participants\":[],\"time_hint\":\"\",\"location\":\"\",\"atmosphere_summary\":\"\",\"progression_note\":\"\",\"beat_maturity\":0,\"world_tension_summary\":\"\",\"should_offer_scene_shift\":false,\"scene_shift_reason\":\"\"}", ] ) return [ @@ -500,9 +556,9 @@ def build_dialogue_relation_state_messages( } ) current_state = { - "relation_delta": dict(session.get("state", {}).get("relation_delta", {}) or {}), - "character_snapshots": dict(session.get("state", {}).get("character_snapshots", {}) or {}), - "event_signals": dict(session.get("state", {}).get("event_signals", {}) or {}), + "relation_delta": _canonical_relation_delta(session), + "character_snapshots": _canonical_character_snapshots(session), + "event_signals": _canonical_event_signals(session), } payload = { "participants": [str(item).strip() for item in list(session.get("participants", []) or []) if str(item).strip()], @@ -578,12 +634,20 @@ def clean_names(value: Any) -> list[str]: present = clean_names(parsed.get("present_participants", [])) offstage = [name for name in clean_names(parsed.get("offstage_participants", [])) if name not in present] + try: + beat_maturity = max(0, min(100, int(parsed.get("beat_maturity", 0) or 0))) + except Exception: + beat_maturity = 0 + return { "present_participants": present, "offstage_participants": offstage, "time_hint": _trim_text(str(parsed.get("time_hint", "")).strip(), 40), "location": _trim_text(str(parsed.get("location", "")).strip(), 40), + "atmosphere_summary": _trim_text(str(parsed.get("atmosphere_summary", "")).strip(), 80), "progression_note": _trim_text(str(parsed.get("progression_note", "")).strip(), 120), + "beat_maturity": beat_maturity, + "world_tension_summary": _trim_text(str(parsed.get("world_tension_summary", "")).strip(), 120), "should_offer_scene_shift": bool(parsed.get("should_offer_scene_shift", False)), "scene_shift_reason": _trim_text(str(parsed.get("scene_shift_reason", "")).strip(), 120), } @@ -636,10 +700,16 @@ def pair_key(left: str, right: str) -> str: amount = 0 if amount: normalized[field] = max(-3, min(3, amount)) - for field in ("last_event", "relation_change", "typical_interaction"): + for field in ("last_event", "relation_change", "typical_interaction", "last_actor", "last_target", "updated_at"): value = _trim_text(str(item.get(field, "")).strip(), 120) if value: normalized[field] = value + try: + momentum = int(item.get("momentum", 0) or 0) + except Exception: + momentum = 0 + if momentum: + normalized["momentum"] = max(0, min(10, momentum)) evidence_lines = [ _trim_text(str(line).strip(), 180) for line in list(item.get("evidence_lines", []) or []) diff --git a/src/web/chat/service.py b/src/web/chat/service.py index 561a9bc..3f7e1c2 100644 --- a/src/web/chat/service.py +++ b/src/web/chat/service.py @@ -22,6 +22,7 @@ def _utc_now() -> str: class DialogueService: + SESSION_STATE_VERSION = 1 _SCENE_ENTER_TOKENS = ("进门", "入内", "走进", "转入", "移步", "到了", "回到", "落座", "入座", "上楼", "进屋", "推门而入") _SCENE_EXIT_TOKENS = ("出去", "离开", "退场", "回房", "回家", "出门", "走远", "散去", "下楼", "离席") _ACTION_TOKENS = ("抬头", "低头", "笑", "沉默", "转身", "皱眉", "顿住", "垂眼", "抿唇", "抬眼", "偏头", "停住", "看向") @@ -65,6 +66,229 @@ def __init__( self._memory_store_resolver = memory_store_resolver self._memory_stores: dict[str, MarkdownSessionStore] = {} + @classmethod + def _empty_session_state(cls) -> dict[str, Any]: + return { + "version": cls.SESSION_STATE_VERSION, + "scene": { + "location": "", + "time_hint": "", + "atmosphere_summary": "", + "progression_note": "", + "updated_at": "", + }, + "presence": { + "present_participants": [], + "offstage_participants": [], + "updated_at": "", + }, + "progression": { + "should_offer_scene_shift": False, + "scene_shift_reason": "", + "turns_in_current_scene": 0, + "beat_maturity": 0, + "world_tension_summary": "", + "updated_at": "", + }, + "relations": { + "matrix": {}, + "delta": {}, + }, + "characters": { + "snapshots": {}, + }, + "signals": cls._empty_event_signals_state(), + "memory": { + "summary": {}, + }, + } + + def _ensure_session_state(self, session: dict[str, Any]) -> dict[str, Any]: + state = dict(session.get("state", {}) or {}) + canonical = self._empty_session_state() + canonical["version"] = int(state.get("version", self.SESSION_STATE_VERSION) or self.SESSION_STATE_VERSION) + + scene = dict(state.get("scene", {}) or {}) + scene_legacy = dict(state.get("scene_progress", {}) or {}) + canonical["scene"] = { + **dict(canonical.get("scene", {}) or {}), + **{key: value for key, value in scene.items() if key in {"location", "time_hint", "atmosphere_summary", "progression_note", "updated_at"}}, + **{ + key: value + for key, value in scene_legacy.items() + if key in {"location", "time_hint", "atmosphere_summary", "progression_note", "updated_at"} + }, + } + + presence = dict(state.get("presence", {}) or {}) + canonical["presence"] = { + **dict(canonical.get("presence", {}) or {}), + **{ + "present_participants": list(presence.get("present_participants", []) or scene_legacy.get("present_participants", []) or []), + "offstage_participants": list(presence.get("offstage_participants", []) or scene_legacy.get("offstage_participants", []) or []), + "updated_at": str(presence.get("updated_at", "")).strip() or str(scene_legacy.get("updated_at", "")).strip(), + }, + } + + progression = dict(state.get("progression", {}) or {}) + canonical["progression"] = { + **dict(canonical.get("progression", {}) or {}), + **{ + "should_offer_scene_shift": bool( + progression.get("should_offer_scene_shift", scene_legacy.get("should_offer_scene_shift", False)) + ), + "scene_shift_reason": str( + progression.get("scene_shift_reason", scene_legacy.get("scene_shift_reason", "")) + ).strip(), + "turns_in_current_scene": int( + progression.get("turns_in_current_scene", scene_legacy.get("turns_in_current_scene", 0)) or 0 + ), + "beat_maturity": int( + progression.get("beat_maturity", scene_legacy.get("beat_maturity", 0)) or 0 + ), + "world_tension_summary": str( + progression.get("world_tension_summary", scene_legacy.get("world_tension_summary", "")) + ).strip(), + "updated_at": str(progression.get("updated_at", "")).strip() or str(scene_legacy.get("updated_at", "")).strip(), + }, + } + + relations = dict(state.get("relations", {}) or {}) + canonical["relations"] = { + "matrix": dict(relations.get("matrix", {}) or state.get("relation_matrix", {}) or {}), + "delta": dict(relations.get("delta", {}) or state.get("relation_delta", {}) or {}), + } + characters = dict(state.get("characters", {}) or {}) + canonical["characters"] = { + "snapshots": dict(characters.get("snapshots", {}) or state.get("character_snapshots", {}) or {}), + } + canonical["signals"] = dict(state.get("signals", {}) or state.get("event_signals", {}) or self._empty_event_signals_state()) + memory = dict(state.get("memory", {}) or {}) + canonical["memory"] = { + "summary": dict(memory.get("summary", {}) or state.get("memory_summary", {}) or {}), + } + session["state"] = canonical + return canonical + + def _session_scene_progress(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + scene = dict(state.get("scene", {}) or {}) + presence = dict(state.get("presence", {}) or {}) + progression = dict(state.get("progression", {}) or {}) + return { + "present_participants": list(presence.get("present_participants", []) or []), + "offstage_participants": list(presence.get("offstage_participants", []) or []), + "time_hint": str(scene.get("time_hint", "")).strip(), + "location": str(scene.get("location", "")).strip(), + "atmosphere_summary": str(scene.get("atmosphere_summary", "")).strip(), + "progression_note": str(scene.get("progression_note", "")).strip(), + "should_offer_scene_shift": bool(progression.get("should_offer_scene_shift", False)), + "scene_shift_reason": str(progression.get("scene_shift_reason", "")).strip(), + "turns_in_current_scene": int(progression.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(progression.get("beat_maturity", 0) or 0), + "world_tension_summary": str(progression.get("world_tension_summary", "")).strip(), + "updated_at": ( + str(progression.get("updated_at", "")).strip() + or str(presence.get("updated_at", "")).strip() + or str(scene.get("updated_at", "")).strip() + ), + } + + def _set_session_scene_progress(self, session: dict[str, Any], scene_progress: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + payload = dict(scene_progress or {}) + updated_at = str(payload.get("updated_at", "")).strip() or _utc_now() + state["scene"] = { + "location": str(payload.get("location", "")).strip(), + "time_hint": str(payload.get("time_hint", "")).strip(), + "atmosphere_summary": str(payload.get("atmosphere_summary", "")).strip(), + "progression_note": str(payload.get("progression_note", "")).strip(), + "updated_at": updated_at, + } + state["presence"] = { + "present_participants": [str(item).strip() for item in list(payload.get("present_participants", []) or []) if str(item).strip()], + "offstage_participants": [str(item).strip() for item in list(payload.get("offstage_participants", []) or []) if str(item).strip()], + "updated_at": updated_at, + } + state["progression"] = { + "should_offer_scene_shift": bool(payload.get("should_offer_scene_shift", False)), + "scene_shift_reason": str(payload.get("scene_shift_reason", "")).strip(), + "turns_in_current_scene": int(payload.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(payload.get("beat_maturity", 0) or 0), + "world_tension_summary": str(payload.get("world_tension_summary", "")).strip(), + "updated_at": updated_at, + } + self._sync_character_runtime_cards(session, payload, updated_at=updated_at) + + def _session_relation_matrix(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("relations", {}).get("matrix", {}) or {}) + + def _set_session_relation_matrix(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("relations", {})["matrix"] = dict(payload or {}) + + def _session_relation_delta(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("relations", {}).get("delta", {}) or {}) + + def _set_session_relation_delta(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("relations", {})["delta"] = dict(payload or {}) + + def _session_character_snapshots(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("characters", {}).get("snapshots", {}) or {}) + + def _set_session_character_snapshots(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("characters", {})["snapshots"] = dict(payload or {}) + + def _sync_character_runtime_cards( + self, + session: dict[str, Any], + scene_progress: dict[str, Any] | None, + *, + updated_at: str, + ) -> None: + state = self._ensure_session_state(session) + snapshots = dict(state.get("characters", {}).get("snapshots", {}) or {}) + progress = dict(scene_progress or {}) + participants = [str(item).strip() for item in list(session.get("participants", []) or []) if str(item).strip()] + present = { + str(item).strip() + for item in list(progress.get("present_participants", []) or []) + if str(item).strip() + } + location = str(progress.get("location", "")).strip() + time_hint = str(progress.get("time_hint", "")).strip() + for name in participants: + current = dict(snapshots.get(name, {}) or {}) + current["present_state"] = "onstage" if name in present else "offstage" + if location: + current["scene_location"] = location + if time_hint: + current["time_hint"] = time_hint + current["updated_at"] = updated_at + snapshots[name] = current + state.setdefault("characters", {})["snapshots"] = snapshots + + def _session_event_signals(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("signals", {}) or {}) + + def _set_session_event_signals(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state["signals"] = dict(payload or self._empty_event_signals_state()) + + def _session_memory_summary_state(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("memory", {}).get("summary", {}) or {}) + + def _set_session_memory_summary_state(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("memory", {})["summary"] = dict(payload or {}) + def list_sessions(self, run_id: str) -> list[dict[str, Any]]: root = self._sessions_root(run_id) items: list[dict[str, Any]] = [] @@ -122,17 +346,12 @@ def create_session( "branch_origin": dict(branch_origin or {}), "history": [], "pending_turn": {}, - "state": { - "scene_progress": {}, - "relation_matrix": self._seed_relation_matrix(run_manifest, selected), - "relation_delta": {}, - "character_snapshots": {}, - "event_signals": self._empty_event_signals_state(), - }, + "state": self._empty_session_state(), "created_at": _utc_now(), "updated_at": _utc_now(), "status": "ready", } + self._set_session_relation_matrix(payload, self._seed_relation_matrix(run_manifest, selected)) if dict(scene_profile or {}): initial_summary = self._build_session_memory_summary(run_id, payload, []) payload["scene_history"] = [ @@ -142,7 +361,7 @@ def create_session( memory_summary=initial_summary, ) ] - payload["state"]["scene_progress"] = self._derive_scene_progress_state(payload, []) + self._set_session_scene_progress(payload, self._derive_scene_progress_state(payload, [])) self._write_json(root / "session.json", payload) if carried_memory_summary: session_store = self._resolve_memory_store(run_id) @@ -194,7 +413,7 @@ def update_scene_card( "ts": _utc_now(), } ) - session.setdefault("state", {})["scene_progress"] = self._derive_scene_progress_state(session, self._serialize_transcript(session)) + self._set_session_scene_progress(session, self._derive_scene_progress_state(session, self._serialize_transcript(session))) transcript = self._serialize_transcript(session) memory_summary = self._build_session_memory_summary(run_id, session, transcript) scene_history = list(session.get("scene_history", []) or []) @@ -218,9 +437,12 @@ def update_scene_progress_state( scene_progress: dict[str, Any] | None = None, ) -> dict[str, Any]: session = self._read_json(self._session_file(run_id, session_id)) - session.setdefault("state", {})["scene_progress"] = self._merge_scene_progress_state( + self._set_session_scene_progress( session, - dict(scene_progress or {}), + self._merge_scene_progress_state( + session, + dict(scene_progress or {}), + ), ) session["updated_at"] = _utc_now() self._write_json(self._session_file(run_id, session_id), session) @@ -458,8 +680,8 @@ def _build_turn_payload( persona_map = {item["name"]: item for item in character_index} relation_graph = dict(run_manifest.get("artifact_index", {}).get("relation_graph", {}) or {}) full_history = list(session.get("history", [])) - scene_progress = dict(session.get("state", {}).get("scene_progress", {}) or {}) - character_snapshots = dict(session.get("state", {}).get("character_snapshots", {}) or {}) + scene_progress = self._session_scene_progress(session) + character_snapshots = self._session_character_snapshots(session) active_participants = self._resolve_active_participants(participants, full_history, mode, speaker, scene_progress) scene_card = dict(session.get("scene_card", {}) or {}) transcript = self._serialize_transcript(session) @@ -638,9 +860,11 @@ def _scene_progress_rule(scene_progress: dict[str, Any]) -> str: offstage = [str(item).strip() for item in list(state.get("offstage_participants", []) or []) if str(item).strip()] time_hint = str(state.get("time_hint", "")).strip() location = str(state.get("location", "")).strip() + atmosphere = str(state.get("atmosphere_summary", "")).strip() note = str(state.get("progression_note", "")).strip() shift = bool(state.get("should_offer_scene_shift", False)) reason = str(state.get("scene_shift_reason", "")).strip() + beat_maturity = int(state.get("beat_maturity", 0) or 0) bits = [ "Respect scene continuity: keep who is present, who already left, and what time/location the scene has drifted to internally consistent.", @@ -651,6 +875,8 @@ def _scene_progress_rule(scene_progress: dict[str, Any]) -> str: details.append(f"time={time_hint}") if location: details.append(f"location={location}") + if atmosphere: + details.append(f"atmosphere={atmosphere}") bits.append(f"Current scene state: {', '.join(details)}.") if present: bits.append(f"Characters currently in-scene: {', '.join(present)}.") @@ -666,6 +892,11 @@ def _scene_progress_rule(scene_progress: dict[str, Any]) -> str: ) if note: bits.append(f"Latest progression note: {note}.") + if beat_maturity: + bits.append(f"Current beat maturity is {beat_maturity}/100; let replies feel appropriately early, settled, or ready to turn.") + tension = str(state.get("world_tension_summary", "")).strip() + if tension: + bits.append(f"Current world tension to carry forward: {tension}.") if shift: bits.append( f"This beat is mature enough to hint a next scene or transition if it helps momentum. Reason: {reason or 'the current beat already feels complete'}." @@ -844,27 +1075,91 @@ def clean_names(values: Any) -> list[str]: "offstage_participants": offstage or [name for name in list(base.get("offstage_participants", []) or []) if name not in present], "time_hint": str(incoming.get("time_hint", "")).strip() or str(base.get("time_hint", "")).strip(), "location": str(incoming.get("location", "")).strip() or str(base.get("location", "")).strip(), + "atmosphere_summary": str(incoming.get("atmosphere_summary", "")).strip() or str(base.get("atmosphere_summary", "")).strip(), "progression_note": str(incoming.get("progression_note", "")).strip() or str(base.get("progression_note", "")).strip(), "should_offer_scene_shift": bool(incoming.get("should_offer_scene_shift", base.get("should_offer_scene_shift", False))), "scene_shift_reason": str(incoming.get("scene_shift_reason", "")).strip() or str(base.get("scene_shift_reason", "")).strip(), "turns_in_current_scene": int(base.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(incoming.get("beat_maturity", base.get("beat_maturity", 0)) or 0), + "world_tension_summary": str(incoming.get("world_tension_summary", "")).strip() or str(base.get("world_tension_summary", "")).strip(), "updated_at": _utc_now(), } + if merged["should_offer_scene_shift"]: + merged["beat_maturity"] = max(75, int(merged.get("beat_maturity", 0) or 0)) return merged def _derive_scene_progress_state(self, session: dict[str, Any], transcript: list[dict[str, Any]]) -> dict[str, Any]: participants = [str(item).strip() for item in list(session.get("participants", []) or []) if str(item).strip()] scene_card = dict(session.get("scene_card", {}) or {}) - prior = dict(session.get("state", {}).get("scene_progress", {}) or {}) + prior = self._session_scene_progress(session) history = list(session.get("history", []) or []) - latest_time_event = self._latest_event_signal(session, "time_change") - latest_scene_event = self._latest_event_signal(session, "scene_transition") - latest_beat_event = self._latest_event_signal(session, "beat_complete") + presence_state = self._derive_presence_state(session, participants=participants, history=history) + scene_frame = self._derive_scene_frame_state(session, transcript=transcript, scene_card=scene_card, prior=prior) + progression_state = self._derive_progression_state( + session, + transcript=transcript, + scene_card=scene_card, + prior=prior, + presence_state=presence_state, + scene_frame=scene_frame, + ) + progression_bits = [] + if scene_frame.get("location"): + progression_bits.append(f"地点:{scene_frame['location']}") + if scene_frame.get("time_hint"): + progression_bits.append(f"时间:{scene_frame['time_hint']}") + if scene_frame.get("atmosphere_summary"): + progression_bits.append(f"氛围:{scene_frame['atmosphere_summary']}") + if presence_state.get("present_participants"): + progression_bits.append(f"在场:{'、'.join(list(presence_state.get('present_participants', []))[:4])}") + if presence_state.get("offstage_participants"): + progression_bits.append(f"离场:{'、'.join(list(presence_state.get('offstage_participants', []))[:3])}") + progression_bits.append(f"成熟度:{int(progression_state.get('beat_maturity', 0) or 0)}") + progression_note = ";".join(bit for bit in progression_bits if bit) + return { + **presence_state, + **scene_frame, + **progression_state, + "progression_note": progression_note, + "updated_at": _utc_now(), + } + + def _derive_presence_state( + self, + session: dict[str, Any], + *, + participants: list[str], + history: list[dict[str, Any]], + ) -> dict[str, Any]: departed = self._infer_departed_participants(participants, history) + latest_exit = self._latest_event_signal(session, "cast_exit") + latest_enter = self._latest_event_signal(session, "cast_enter") + if latest_exit: + actor = str(latest_exit.get("actor", "")).strip() + if actor in participants: + departed.add(actor) + if latest_enter: + actor = str(latest_enter.get("actor", "")).strip() + if actor in participants: + departed.discard(actor) present = [name for name in participants if name not in departed] if not present and participants: present = participants[:1] - offstage = [name for name in participants if name not in present] + return { + "present_participants": present, + "offstage_participants": [name for name in participants if name not in present], + } + + def _derive_scene_frame_state( + self, + session: dict[str, Any], + *, + transcript: list[dict[str, Any]], + scene_card: dict[str, Any], + prior: dict[str, Any], + ) -> dict[str, Any]: + latest_time_event = self._latest_event_signal(session, "time_change") + latest_scene_event = self._latest_event_signal(session, "scene_transition") time_hint = ( str(latest_time_event.get("time_hint", "")).strip() or self._infer_time_hint(transcript) @@ -876,41 +1171,142 @@ def _derive_scene_progress_state(self, session: dict[str, Any], transcript: list or str(prior.get("location", "")).strip() or str(scene_card.get("location", "")).strip() ) + latest_atmosphere_event = self._latest_event_signal(session, "atmosphere_shift") + atmosphere_summary = ( + self._trim_summary_text(str(latest_atmosphere_event.get("cue", "")).strip(), 80) + or self._infer_atmosphere_summary(transcript) + or self._trim_summary_text(str(prior.get("atmosphere_summary", "")).strip(), 80) + or self._trim_summary_text(str(scene_card.get("atmosphere", "")).strip(), 80) + ) + return { + "time_hint": time_hint, + "location": location, + "atmosphere_summary": atmosphere_summary, + } + + def _derive_progression_state( + self, + session: dict[str, Any], + *, + transcript: list[dict[str, Any]], + scene_card: dict[str, Any], + prior: dict[str, Any], + presence_state: dict[str, Any], + scene_frame: dict[str, Any], + ) -> dict[str, Any]: + latest_beat_event = self._latest_event_signal(session, "beat_complete") turns_in_current_scene = self._count_current_scene_turns(session) + beat_maturity = self._estimate_scene_maturity( + turns_in_current_scene=turns_in_current_scene, + transcript=transcript, + scene_card=scene_card, + presence_state=presence_state, + scene_frame=scene_frame, + latest_beat_event=latest_beat_event, + prior=prior, + ) scene_shift_reason = "" should_offer_scene_shift = False - if scene_card and turns_in_current_scene >= 8: + if scene_card and beat_maturity >= 72: should_offer_scene_shift = True scene_shift_reason = "这一幕已经接了好几拍,可以顺势换到下一幕。" if latest_beat_event: should_offer_scene_shift = True scene_shift_reason = str(latest_beat_event.get("cue", "")).strip() or scene_shift_reason initial_time = str(scene_card.get("time_hint", "")).strip() - if time_hint and initial_time and time_hint != initial_time and turns_in_current_scene >= 5: + time_hint = str(scene_frame.get("time_hint", "")).strip() + if time_hint and initial_time and time_hint != initial_time and beat_maturity >= 55: should_offer_scene_shift = True scene_shift_reason = scene_shift_reason or f"时间已经自然推到{time_hint},适合顺势转下一拍。" - progression_bits = [] - if location: - progression_bits.append(f"地点:{location}") - if time_hint: - progression_bits.append(f"时间:{time_hint}") - if present: - progression_bits.append(f"在场:{'、'.join(present[:4])}") - if offstage: - progression_bits.append(f"离场:{'、'.join(offstage[:3])}") - progression_note = ";".join(progression_bits) return { - "present_participants": present, - "offstage_participants": offstage, - "time_hint": time_hint, - "location": location, - "progression_note": progression_note, "should_offer_scene_shift": should_offer_scene_shift, "scene_shift_reason": scene_shift_reason, "turns_in_current_scene": turns_in_current_scene, - "updated_at": _utc_now(), + "beat_maturity": beat_maturity, + "world_tension_summary": self._derive_world_tension_summary(session, transcript=transcript, scene_frame=scene_frame), } + def _estimate_scene_maturity( + self, + *, + turns_in_current_scene: int, + transcript: list[dict[str, Any]], + scene_card: dict[str, Any], + presence_state: dict[str, Any], + scene_frame: dict[str, Any], + latest_beat_event: dict[str, Any], + prior: dict[str, Any], + ) -> int: + score = min(60, max(0, turns_in_current_scene * 10)) + if latest_beat_event: + score += 25 + if str(scene_frame.get("time_hint", "")).strip() and str(scene_frame.get("time_hint", "")).strip() != str(scene_card.get("time_hint", "")).strip(): + score += 10 + if str(scene_frame.get("location", "")).strip() and str(scene_frame.get("location", "")).strip() != str(scene_card.get("location", "")).strip(): + score += 10 + if list(presence_state.get("offstage_participants", []) or []): + score += 6 + if str(scene_frame.get("atmosphere_summary", "")).strip(): + score += 4 + previous_maturity = int(prior.get("beat_maturity", 0) or 0) + if previous_maturity: + score = max(score, min(100, previous_maturity - 8)) + if len(transcript) >= 6: + score += 6 + return max(0, min(100, score)) + + def _infer_atmosphere_summary(self, transcript: list[dict[str, Any]]) -> str: + recent_messages = [ + str(item.get("message", "")).strip() + for item in list(transcript or [])[-8:] + if str(item.get("message", "")).strip() + ] + if not recent_messages: + return "" + joined = " ".join(recent_messages) + for token in self._ATMOSPHERE_TOKENS: + if token in joined: + return self._trim_summary_text(token, 40) + for message in reversed(recent_messages): + trimmed = self._trim_summary_text(message, 40) + if trimmed: + return trimmed + return "" + + def _derive_world_tension_summary( + self, + session: dict[str, Any], + *, + transcript: list[dict[str, Any]], + scene_frame: dict[str, Any], + ) -> str: + latest_atmosphere_event = self._latest_event_signal(session, "atmosphere_shift") + latest_relation_event = self._latest_event_signal(session, "relationship_shift") + latest_scene_event = self._latest_event_signal(session, "scene_transition", "environment_change", "time_change") + for candidate in (latest_atmosphere_event, latest_relation_event, latest_scene_event): + cue = self._trim_summary_text(str((candidate or {}).get("cue", "")).strip(), 88) + if cue: + return cue + relation_delta = self._session_relation_delta(session) + if relation_delta: + pair_key, delta = next(iter(relation_delta.items())) + metrics: list[str] = [] + for field, label in (("trust", "信任"), ("affection", "好感"), ("hostility", "敌意"), ("ambiguity", "摇摆")): + amount = int(dict(delta or {}).get(field, 0) or 0) + if amount: + metrics.append(f"{label}{amount:+d}") + if metrics: + return self._trim_summary_text(f"{pair_key} 当前仍在变化:{'、'.join(metrics)}", 88) + atmosphere = str(scene_frame.get("atmosphere_summary", "")).strip() + if atmosphere: + return self._trim_summary_text(f"这一拍的气氛是:{atmosphere}", 88) + for item in reversed(list(transcript or [])[-8:]): + role = str(item.get("role", "")).strip() + message = self._trim_summary_text(str(item.get("message", "")).strip(), 88) + if role in {"scene", "director"} and message: + return message + return "" + @staticmethod def _infer_time_hint(transcript: list[dict[str, Any]]) -> str: tokens = ( @@ -1075,13 +1471,12 @@ def _seed_relation_matrix(self, run_manifest: dict[str, Any], participants: list return keys def _merged_relation_matrix(self, session: dict[str, Any], participants: list[str]) -> dict[str, Any]: - state = dict(session.get("state", {}) or {}) base = { str(key).strip(): self._normalize_relation_entry(dict(value or {})) - for key, value in dict(state.get("relation_matrix", {}) or {}).items() + for key, value in self._session_relation_matrix(session).items() if str(key).strip() } - deltas = dict(state.get("relation_delta", {}) or {}) + deltas = self._session_relation_delta(session) selected = [str(item).strip() for item in list(participants or []) if str(item).strip()] for index, left in enumerate(selected): for right in selected[index + 1 :]: @@ -1101,10 +1496,15 @@ def _merged_relation_matrix(self, session: dict[str, Any], participants: list[st step = 0 baseline = int(merged.get(field, self._default_relation_entry()[field]) or self._default_relation_entry()[field]) merged[field] = max(0, min(10, baseline + step)) - for field in ("last_event", "relation_change", "typical_interaction"): + for field in ("last_event", "relation_change", "typical_interaction", "last_actor", "last_target", "updated_at"): value = str(delta_payload.get(field, "")).strip() if value: merged[field] = value + if "momentum" in delta_payload: + try: + merged["momentum"] = int(delta_payload.get("momentum", 0) or 0) + except Exception: + pass evidence_lines = list(merged.get("evidence_lines", []) or []) for item in list(delta_payload.get("evidence_lines", []) or []): text = str(item).strip() @@ -1124,7 +1524,7 @@ def _empty_event_signals_state() -> dict[str, Any]: } def _merge_event_signals_state(self, session: dict[str, Any], incoming: list[dict[str, Any]]) -> dict[str, Any]: - current = dict(session.get("state", {}).get("event_signals", {}) or {}) + current = self._session_event_signals(session) recent = [ dict(item or {}) for item in list(current.get("recent", []) or []) @@ -1214,12 +1614,11 @@ def normalize_event(item: dict[str, Any]) -> dict[str, Any]: "updated_at": _utc_now(), } - @staticmethod - def _latest_event_signal(session: dict[str, Any], *kinds: str) -> dict[str, Any]: + def _latest_event_signal(self, session: dict[str, Any], *kinds: str) -> dict[str, Any]: wanted = {str(item).strip() for item in kinds if str(item).strip()} if not wanted: return {} - recent = list(dict(session.get("state", {}).get("event_signals", {}) or session.get("event_signals", {}) or {}).get("recent", []) or []) + recent = list(self._session_event_signals(session).get("recent", []) or []) for item in reversed(recent): event = dict(item or {}) if str(event.get("kind", "")).strip() in wanted: @@ -1233,7 +1632,7 @@ def _build_session_relation_excerpt( participants: list[str], active_participants: list[str], ) -> str: - deltas = dict(session.get("state", {}).get("relation_delta", {}) or {}) + deltas = self._session_relation_delta(session) if not deltas: return "" merged = self._merged_relation_matrix(session, participants) @@ -1267,13 +1666,17 @@ def _build_session_relation_excerpt( last_event = str(delta.get("last_event", "")).strip() if last_event: line = f"{line}\n- last_event: {self._trim_summary_text(last_event, 120)}" + last_actor = str(delta.get("last_actor", "")).strip() + last_target = str(delta.get("last_target", "")).strip() + if last_actor or last_target: + line = f"{line}\n- drift: {self._trim_summary_text(' -> '.join([item for item in (last_actor, last_target) if item]), 80)}" lines.append(line) if len("\n".join(lines)) >= 1200: break return "\n".join(lines).strip() def _build_session_event_excerpt(self, session: dict[str, Any]) -> list[dict[str, Any]]: - event_signals = dict(session.get("state", {}).get("event_signals", {}) or {}) + event_signals = self._session_event_signals(session) recent = list(event_signals.get("recent", []) or []) normalized: list[dict[str, Any]] = [] for item in recent[-8:]: @@ -1408,9 +1811,13 @@ def _persona_snapshot_payload(snapshot: dict[str, Any], *, detailed: bool) -> di "focus": str(snapshot.get("focus", "")).strip(), "last_target": str(snapshot.get("last_target", "")).strip(), "last_message": str(snapshot.get("last_message", "")).strip(), + "present_state": str(snapshot.get("present_state", "")).strip(), + "scene_location": str(snapshot.get("scene_location", "")).strip(), + "time_hint": str(snapshot.get("time_hint", "")).strip(), } if detailed: fields["last_event"] = str(snapshot.get("last_event", "")).strip() + fields["updated_at"] = str(snapshot.get("updated_at", "")).strip() return {key: value for key, value in fields.items() if value} def _build_relation_excerpt( @@ -1502,7 +1909,7 @@ def _build_turn_memory_context( scene_card: dict[str, Any], scene_progress: dict[str, Any] | None = None, ) -> dict[str, Any]: - state_summary = dict(session.get("state", {}).get("memory_summary", {}) or {}) + state_summary = self._session_memory_summary_state(session) archived_summary = { "summary": self._trim_summary_text(str(state_summary.get("summary", "")).strip(), 360), "key_points": [ @@ -1535,6 +1942,7 @@ def _build_turn_memory_context( ], "should_offer_scene_shift": bool(normalized_progress.get("should_offer_scene_shift", False)), "scene_shift_reason": self._trim_summary_text(str(normalized_progress.get("scene_shift_reason", "")).strip(), 120), + "world_tension_summary": self._trim_summary_text(str(normalized_progress.get("world_tension_summary", "")).strip(), 120), } progress_snapshot = { key: value @@ -1543,7 +1951,7 @@ def _build_turn_memory_context( } character_snapshots = { str(name).strip(): self._persona_snapshot_payload(dict(snapshot or {}), detailed=True) - for name, snapshot in dict(session.get("state", {}).get("character_snapshots", {}) or {}).items() + for name, snapshot in self._session_character_snapshots(session).items() if str(name).strip() and self._persona_snapshot_payload(dict(snapshot or {}), detailed=True) } relation_delta = { @@ -1552,7 +1960,7 @@ def _build_turn_memory_context( for key, value in dict(delta or {}).items() if value not in ("", [], 0, None) } - for pair_key, delta in dict(session.get("state", {}).get("relation_delta", {}) or {}).items() + for pair_key, delta in self._session_relation_delta(session).items() if str(pair_key).strip() } relation_delta = {key: value for key, value in relation_delta.items() if value} @@ -1644,10 +2052,10 @@ def _serialize_session(self, run_id: str, payload: dict[str, Any]) -> dict[str, session["mode_display"] = self._mode_display(str(session.get("mode", "")).strip()) transcript = self._serialize_transcript(session) session["transcript"] = transcript - session["scene_progress"] = dict(session.get("state", {}).get("scene_progress", {}) or {}) - session["relation_delta"] = dict(session.get("state", {}).get("relation_delta", {}) or {}) - session["character_snapshots"] = dict(session.get("state", {}).get("character_snapshots", {}) or {}) - session["event_signals"] = dict(session.get("state", {}).get("event_signals", {}) or {}) + session["scene_progress"] = self._session_scene_progress(session) + session["relation_delta"] = self._session_relation_delta(session) + session["character_snapshots"] = self._session_character_snapshots(session) + session["event_signals"] = self._session_event_signals(session) session["relation_matrix"] = self._merged_relation_matrix(session, list(session.get("participants", []) or [])) session["last_entry_preview"] = self._build_last_entry_preview(session) session["session_card"] = self._build_session_card(session) @@ -1655,6 +2063,7 @@ def _serialize_session(self, run_id: str, payload: dict[str, Any]) -> dict[str, session["branch_origin"] = dict(session.get("branch_origin", {}) or {}) session["pending_turn_summary"] = self._build_pending_turn_summary(session) session["session_memory_summary"] = self._build_session_memory_summary(run_id, session, transcript) + session["runtime_state_overview"] = self._build_runtime_state_overview(session) return session def _serialize_transcript(self, session: dict[str, Any]) -> list[dict[str, Any]]: @@ -1763,12 +2172,175 @@ def _build_pending_turn_summary(self, session: dict[str, Any]) -> dict[str, Any] "response_limit_hint": int(pending.get("response_limit_hint", 0) or 0), } + def _build_runtime_state_overview(self, session: dict[str, Any]) -> dict[str, Any]: + scene_progress = self._session_scene_progress(session) + present = [ + str(item).strip() + for item in list(scene_progress.get("present_participants", []) or []) + if str(item).strip() + ] + offstage = [ + str(item).strip() + for item in list(scene_progress.get("offstage_participants", []) or []) + if str(item).strip() + ] + location = str(scene_progress.get("location", "")).strip() + time_hint = str(scene_progress.get("time_hint", "")).strip() + atmosphere = self._trim_summary_text(str(scene_progress.get("atmosphere_summary", "")).strip(), 80) + beat_maturity = max(0, min(100, int(scene_progress.get("beat_maturity", 0) or 0))) + should_offer_scene_shift = bool(scene_progress.get("should_offer_scene_shift", False)) + shift_reason = self._trim_summary_text(str(scene_progress.get("scene_shift_reason", "")).strip(), 120) + tension = self._trim_summary_text(str(scene_progress.get("world_tension_summary", "")).strip(), 120) + + pills: list[dict[str, Any]] = [] + if location: + pills.append({"text": f"地点 · {location}"}) + if time_hint: + pills.append({"text": f"时间 · {time_hint}"}) + if atmosphere: + pills.append({"text": f"氛围 · {atmosphere}"}) + if beat_maturity > 0: + pills.append({"text": f"推进 {beat_maturity}/100"}) + if should_offer_scene_shift: + pills.append({"text": f"可转场 · {shift_reason or '这一拍已经可以顺势转场'}"}) + + character_rows: list[dict[str, Any]] = [] + for name, snapshot in self._session_character_snapshots(session).items(): + normalized_name = str(name).strip() + if not normalized_name: + continue + current = dict(snapshot or {}) + parts: list[str] = [] + present_state = str(current.get("present_state", "")).strip() + if present_state == "onstage": + parts.append("在场") + elif present_state == "offstage": + parts.append("离场") + for key in ("mood", "interaction_state"): + value = str(current.get(key, "")).strip() + if value: + parts.append(value) + focus = str(current.get("focus", "")).strip() + if focus: + parts.append(f"看向 {focus}") + character_rows.append( + { + "title": normalized_name, + "copy": self._trim_summary_text(" · ".join(parts) or "这一拍还没有额外漂移。", 120), + "rank": 0 if present_state == "onstage" else 1, + } + ) + character_rows.sort(key=lambda item: (int(item.get("rank", 9) or 9), str(item.get("title", "")))) + character_rows = [{"title": item["title"], "copy": item["copy"]} for item in character_rows[:4]] + + relation_rows: list[dict[str, Any]] = [] + for pair_key, delta in self._session_relation_delta(session).items(): + normalized_key = str(pair_key).strip() + if not normalized_key: + continue + payload = dict(delta or {}) + metrics: list[str] = [] + momentum = int(payload.get("momentum", 0) or 0) + for field, label in (("trust", "信任"), ("affection", "好感"), ("hostility", "敌意"), ("ambiguity", "摇摆")): + amount = int(payload.get(field, 0) or 0) + if amount: + metrics.append(f"{label}{amount:+d}") + last_event = self._trim_summary_text(str(payload.get("last_event", "")).strip(), 72) + relation_rows.append( + { + "title": normalized_key.replace("_", " · "), + "copy": self._trim_summary_text( + f"{' / '.join(metrics)}{' · ' if metrics and last_event else ''}{last_event}".strip() or "这组关系本局有变化。", + 120, + ), + "rank": max(momentum, len(metrics)), + } + ) + relation_rows.sort(key=lambda item: (-int(item.get("rank", 0) or 0), str(item.get("title", "")))) + relation_rows = [{"title": item["title"], "copy": item["copy"]} for item in relation_rows[:3]] + + event_rows: list[dict[str, str]] = [] + for event in list(self._session_event_signals(session).get("recent", []) or [])[-4:]: + payload = dict(event or {}) + kind = str(payload.get("kind", "")).strip() + cue = self._trim_summary_text(str(payload.get("cue", "")).strip(), 88) + if not kind or not cue: + continue + actor = str(payload.get("actor", "")).strip() + target = str(payload.get("target", "")).strip() + scope = str(payload.get("scope", "")).strip() + title_bits = [self._event_kind_label(kind)] + if actor: + title_bits.append(actor) + if target: + title_bits.append(target) + event_rows.append( + { + "title": " · ".join(title_bits) if title_bits else (scope or "event"), + "copy": cue, + } + ) + + status_bits: list[str] = [] + pill_texts = [str(item.get("text", "")).strip() for item in pills if str(item.get("text", "")).strip()] + if pill_texts: + status_bits.append(" · ".join(pill_texts[:3])) + if present: + status_bits.append(f"在场:{'、'.join(present[:3])}") + if offstage: + status_bits.append(f"离场:{'、'.join(offstage[:2])}") + if tension: + status_bits.append(f"张力:{self._trim_summary_text(tension, 56)}") + status_line = " | ".join(status_bits) + + next_hint = "" + if should_offer_scene_shift: + next_hint = shift_reason or "这一拍已经可以顺势转场。" + elif tension: + next_hint = self._trim_summary_text(tension, 72) + elif event_rows: + next_hint = self._trim_summary_text(str(event_rows[-1].get("copy", "")).strip(), 72) + + return { + "present": present, + "offstage": offstage, + "location": location, + "time_hint": time_hint, + "atmosphere": atmosphere, + "beat_maturity": beat_maturity, + "should_offer_scene_shift": should_offer_scene_shift, + "scene_shift_reason": shift_reason, + "tension": tension, + "pills": pills, + "character_rows": character_rows, + "relation_rows": relation_rows, + "event_rows": event_rows, + "status_line": status_line, + "next_hint": next_hint, + } + + @staticmethod + def _event_kind_label(kind: str) -> str: + mapping = { + "scene_transition": "转场", + "cast_enter": "入场", + "cast_exit": "离场", + "atmosphere_shift": "气氛变化", + "time_change": "时间推进", + "environment_change": "环境变化", + "beat_complete": "一拍收束", + "relationship_shift": "关系变化", + "micro_action": "细微动作", + } + normalized = str(kind or "").strip() + return mapping.get(normalized, normalized or "事件") + def _build_session_memory_summary(self, run_id: str, session: dict[str, Any], transcript: list[dict[str, Any]]) -> dict[str, str]: mode = str(session.get("mode", "observe")).strip() or "observe" mode_display = self._mode_display(mode) participants = [str(item).strip() for item in session.get("participants", []) if str(item).strip()] history = list(session.get("history", []) or []) - scene_progress = dict(session.get("state", {}).get("scene_progress", {}) or session.get("scene_progress", {}) or {}) + scene_progress = self._session_scene_progress(session) present_participants = [ str(item).strip() for item in list(scene_progress.get("present_participants", []) or []) @@ -1840,7 +2412,10 @@ def _build_session_memory_summary(self, run_id: str, session: dict[str, Any], tr perspective = f"{perspective} 当前时间已经推进到「{time_hint}」。" world = "当前局势里的动作与情绪线会在这里提醒你。" - if progression_note: + world_tension_summary = str(scene_progress.get("world_tension_summary", "")).strip() + if world_tension_summary: + world = self._trim_summary_text(world_tension_summary, 88) + elif progression_note: world = self._trim_summary_text(progression_note, 88) for item in reversed(transcript): role = str(item.get("role", "")).strip() @@ -1883,7 +2458,7 @@ def _build_session_memory_summary(self, run_id: str, session: dict[str, Any], tr semantic_hint = str((hits[0] or {}).get("text", "")).strip() if semantic_hint: relation = f"{relation} · 长期记忆:{self._trim_summary_text(semantic_hint, 68)}" - relation_delta = dict(session.get("state", {}).get("relation_delta", {}) or {}) + relation_delta = self._session_relation_delta(session) if relation_delta: delta_bits: list[str] = [] for pair_key, delta in list(relation_delta.items())[:3]: diff --git a/src/web/service_facades/dialogue.py b/src/web/service_facades/dialogue.py index 20e232f..9c3b1b9 100644 --- a/src/web/service_facades/dialogue.py +++ b/src/web/service_facades/dialogue.py @@ -297,7 +297,7 @@ def _generate_dialogue_scene_progress(self, run_id: str, session: dict[str, Any] return {} payload = dict(session or {}) - payload["scene_progress"] = dict(payload.get("scene_progress", {}) or payload.get("state", {}).get("scene_progress", {}) or {}) + payload["scene_progress"] = self.dialogue._session_scene_progress(payload) attempts = ( build_dialogue_scene_progress_messages(payload), [ @@ -356,10 +356,9 @@ def _evolve_relations_from_turn( return session_path = self.dialogue._session_file(run_id, session_id) session = self.dialogue._read_json(session_path) - state = dict(session.get("state", {}) or {}) - relation_delta = dict(state.get("relation_delta", {}) or {}) - character_snapshots = dict(state.get("character_snapshots", {}) or {}) - event_signals = dict(state.get("event_signals", {}) or self.dialogue._empty_event_signals_state()) + relation_delta = self.dialogue._session_relation_delta(session) + character_snapshots = self.dialogue._session_character_snapshots(session) + event_signals = self.dialogue._session_event_signals(session) input_block = dict(pending_payload.get("input", {}) or {}) speaker = str(input_block.get("speaker", "")).strip() participants = [str(item).strip() for item in input_block.get("participants", []) if str(item).strip()] @@ -397,10 +396,18 @@ def _evolve_relations_from_turn( continue current[field] = int(current.get(field, 0) or 0) + int(amount) current["last_event"] = message[:220] + current["last_actor"] = responder + current["last_target"] = target evidence_lines = list(current.get("evidence_lines", []) or []) evidence_lines.append(f"{responder}->{target}: {message}"[:220]) current["evidence_lines"] = evidence_lines[-10:] current["updated_at"] = session.get("updated_at", "") + current["momentum"] = max( + abs(int(current.get("trust", 0) or 0)), + abs(int(current.get("affection", 0) or 0)), + abs(int(current.get("hostility", 0) or 0)), + abs(int(current.get("ambiguity", 0) or 0)), + ) relation_delta[key] = current if any(int(current.get(field, 0) or 0) for field in ("trust", "affection", "hostility", "ambiguity")): detected_events.append( @@ -459,9 +466,9 @@ def _evolve_relations_from_turn( detected_events, ) - session.setdefault("state", {})["relation_delta"] = relation_delta - session.setdefault("state", {})["character_snapshots"] = character_snapshots - session.setdefault("state", {})["event_signals"] = event_signals + self.dialogue._set_session_relation_delta(session, relation_delta) + self.dialogue._set_session_character_snapshots(session, character_snapshots) + self.dialogue._set_session_event_signals(session, event_signals) session["updated_at"] = session.get("updated_at") or "" self.dialogue._write_json(session_path, session) store = self.dialogue._resolve_memory_store(run_id) @@ -494,10 +501,8 @@ def _generate_dialogue_relation_state( return {} payload = dict(session or {}) - payload.setdefault("state", {}) - payload["state"] = dict(payload.get("state", {}) or {}) - payload["state"]["relation_delta"] = dict(relation_delta or {}) - payload["state"]["character_snapshots"] = dict(character_snapshots or {}) + self.dialogue._set_session_relation_delta(payload, relation_delta) + self.dialogue._set_session_character_snapshots(payload, character_snapshots) attempts = ( build_dialogue_relation_state_messages(payload, pending_payload, responses), [ @@ -538,10 +543,15 @@ def _merge_relation_delta(base: dict[str, Any], incoming: dict[str, Any]) -> dic current[field] = int(next_value.get(field, 0) or 0) except Exception: pass - for field in ("last_event", "relation_change", "typical_interaction"): + for field in ("last_event", "relation_change", "typical_interaction", "last_actor", "last_target", "updated_at"): text = str(next_value.get(field, "")).strip() if text: current[field] = text + if "momentum" in next_value: + try: + current["momentum"] = int(next_value.get("momentum", 0) or 0) + except Exception: + pass evidence_lines = [ str(item).strip() for item in list(next_value.get("evidence_lines", []) or []) diff --git a/tests/test_session_store.py b/tests/test_session_store.py index 4ab7846..3a3ea75 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -24,8 +24,10 @@ def test_markdown_session_store_persists_session_and_relation_snapshot(self): "updated_at": 1234567890, "characters": ["刘备", "关羽"], "state": { - "relation_matrix": {"关羽_刘备": {"trust": 9}}, - "relation_delta": {"关羽_刘备": {"trust": 10}}, + "relations": { + "matrix": {"关羽_刘备": {"trust": 9}}, + "delta": {"关羽_刘备": {"trust": 10}}, + } }, } @@ -55,12 +57,12 @@ def test_session_store_compresses_context_and_supports_long_term_search(self): {"speaker": "林黛玉", "message": f"第{i}句提到了宝玉和心事。", "ts": i} for i in range(32) ], - "state": {}, + "state": {"memory": {"summary": {}}}, } updated = store.compress_context(session) self.assertLess(len(updated["history"]), 32) - memory_summary = updated.get("state", {}).get("memory_summary", {}) + memory_summary = updated.get("state", {}).get("memory", {}).get("summary", {}) self.assertTrue(memory_summary.get("summary")) self.assertGreater(memory_summary.get("compressed_turns", 0), 0) diff --git a/tests/test_web_app.py b/tests/test_web_app.py index db3974e..ec4c313 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -2964,11 +2964,13 @@ def test_build_turn_payload_includes_memory_context_and_trims_relation_excerpt(s {"speaker": "贾宝玉", "message": "我不是有意惹你心烦。", "ts": "2026-05-12T00:00:01Z"}, ] raw_session["state"] = { - "memory_summary": { - "summary": "两人前面已经因一句话生过闷气,但都还惦记对方。", - "key_points": ["林黛玉嘴上轻冷,心里还在意。", "贾宝玉想解释,却总把话说得更乱。"], - "compressed_turns": 18, - "recent_turns_kept": 24, + "memory": { + "summary": { + "summary": "两人前面已经因一句话生过闷气,但都还惦记对方。", + "key_points": ["林黛玉嘴上轻冷,心里还在意。", "贾宝玉想解释,却总把话说得更乱。"], + "compressed_turns": 18, + "recent_turns_kept": 24, + } } } service.dialogue._write_json(service.dialogue._session_file(run_id, session["session_id"]), raw_session) @@ -3063,16 +3065,23 @@ def test_dialogue_relation_delta_and_character_snapshot_are_session_isolated(sel raw_one = service.dialogue._read_json(service.dialogue._session_file(run_id, session_one["session_id"])) raw_two = service.dialogue._read_json(service.dialogue._session_file(run_id, session_two["session_id"])) - delta = raw_one.get("state", {}).get("relation_delta", {}).get("林黛玉_贾宝玉", {}) + delta = raw_one.get("state", {}).get("relations", {}).get("delta", {}).get("林黛玉_贾宝玉", {}) self.assertEqual(delta.get("trust"), 1) self.assertEqual(delta.get("affection"), 1) self.assertEqual(delta.get("hostility"), -1) - snapshot = raw_one.get("state", {}).get("character_snapshots", {}).get("贾宝玉", {}) + self.assertEqual(delta.get("last_actor"), "贾宝玉") + self.assertEqual(delta.get("last_target"), "林黛玉") + self.assertGreaterEqual(int(delta.get("momentum", 0) or 0), 1) + snapshot = raw_one.get("state", {}).get("characters", {}).get("snapshots", {}).get("贾宝玉", {}) self.assertEqual(snapshot.get("interaction_state"), "softening") self.assertEqual(snapshot.get("last_target"), "林黛玉") + self.assertEqual(snapshot.get("present_state"), "onstage") + self.assertTrue(bool(snapshot.get("updated_at", ""))) - self.assertEqual(raw_two.get("state", {}).get("relation_delta", {}), {}) - self.assertEqual(raw_two.get("state", {}).get("character_snapshots", {}), {}) + self.assertEqual(raw_two.get("state", {}).get("relations", {}).get("delta", {}), {}) + untouched_snapshot = raw_two.get("state", {}).get("characters", {}).get("snapshots", {}).get("贾宝玉", {}) + self.assertEqual(untouched_snapshot.get("present_state"), "onstage") + self.assertFalse(bool(untouched_snapshot.get("interaction_state", ""))) self.assertEqual(relation_path.read_text(encoding="utf-8"), original_relation_text) def test_build_turn_payload_includes_session_relation_delta_and_snapshots(self): @@ -3111,24 +3120,28 @@ def test_build_turn_payload_includes_session_relation_delta_and_snapshots(self): raw_session = service.dialogue._read_json(service.dialogue._session_file(run_id, session["session_id"])) raw_session["state"] = { **dict(raw_session.get("state", {}) or {}), - "relation_matrix": { - "林黛玉_贾宝玉": {"trust": 8, "affection": 8, "hostility": 1, "ambiguity": 3} - }, - "relation_delta": { - "林黛玉_贾宝玉": { - "trust": 1, - "affection": 1, - "last_event": "刚刚把话说软了下来。", - "evidence_lines": ["贾宝玉->林黛玉: 谢谢你愿意陪我一起。"], - } + "relations": { + "matrix": { + "林黛玉_贾宝玉": {"trust": 8, "affection": 8, "hostility": 1, "ambiguity": 3} + }, + "delta": { + "林黛玉_贾宝玉": { + "trust": 1, + "affection": 1, + "last_event": "刚刚把话说软了下来。", + "evidence_lines": ["贾宝玉->林黛玉: 谢谢你愿意陪我一起。"], + } + }, }, - "character_snapshots": { - "贾宝玉": { - "mood": "放松", - "interaction_state": "softening", - "focus": "林黛玉", - "last_target": "林黛玉", - "last_message": "谢谢你愿意陪我一起。", + "characters": { + "snapshots": { + "贾宝玉": { + "mood": "放松", + "interaction_state": "softening", + "focus": "林黛玉", + "last_target": "林黛玉", + "last_message": "谢谢你愿意陪我一起。", + } } }, } @@ -3148,6 +3161,65 @@ def test_build_turn_payload_includes_session_relation_delta_and_snapshots(self): self.assertIn("session_delta", relation_excerpt) detail_map = {item["name"]: item for item in payload.get("persona_contexts", [])} self.assertEqual(detail_map["贾宝玉"]["session_snapshot"]["interaction_state"], "softening") + serialized = service.dialogue._serialize_session(run_id, raw_session) + overview = dict(serialized.get("runtime_state_overview", {}) or {}) + self.assertTrue(bool(overview.get("relation_rows", []))) + + def test_dialogue_session_state_uses_canonical_grouped_schema(self): + with tempfile.TemporaryDirectory() as tmp: + service = WebRunService(tmp) + service.save_model_settings( + provider="openai-compatible", + model="deepseek-chat", + base_url="https://example.com/v1", + api_key="sk-test", + ) + run = service.create_run( + novel_name="hongloumeng.txt", + novel_content_base64=base64.b64encode("林黛玉见了贾宝玉。".encode("utf-8")).decode("ascii"), + characters=["林黛玉", "贾宝玉"], + ) + run_id = run["run_id"] + for name in ("林黛玉", "贾宝玉"): + service.ingest_character_result( + run_id, + character=name, + content_base64=base64.b64encode( + f"- name: {name}\n- novel_id: hongloumeng\n- core_identity: 人物\n".encode("utf-8") + ).decode("ascii"), + ) + + session = service.dialogue.create_session( + service._require_manifest(run_id), + mode="observe", + participants=["林黛玉", "贾宝玉"], + ) + raw_session = service.dialogue._read_json(service.dialogue._session_file(run_id, session["session_id"])) + state = dict(raw_session.get("state", {}) or {}) + + self.assertEqual(state.get("version"), 1) + self.assertIn("scene", state) + self.assertIn("presence", state) + self.assertIn("progression", state) + self.assertIn("relations", state) + self.assertIn("characters", state) + self.assertIn("signals", state) + self.assertIn("memory", state) + self.assertIn("atmosphere_summary", dict(state.get("scene", {}) or {})) + self.assertIn("matrix", dict(state.get("relations", {}) or {})) + self.assertIn("delta", dict(state.get("relations", {}) or {})) + self.assertIn("snapshots", dict(state.get("characters", {}) or {})) + self.assertIn("beat_maturity", dict(state.get("progression", {}) or {})) + self.assertIn("world_tension_summary", dict(state.get("progression", {}) or {})) + overview = dict(session.get("runtime_state_overview", {}) or {}) + self.assertIn("present", overview) + self.assertIn("offstage", overview) + self.assertIn("pills", overview) + self.assertIn("character_rows", overview) + self.assertIn("relation_rows", overview) + self.assertIn("event_rows", overview) + self.assertIn("status_line", overview) + self.assertIn("next_hint", overview) def test_dialogue_relation_state_llm_can_lightly_refine_session_delta(self): with tempfile.TemporaryDirectory() as tmp: @@ -3214,11 +3286,11 @@ def test_dialogue_relation_state_llm_can_lightly_refine_session_delta(self): ) raw_session = service.dialogue._read_json(service.dialogue._session_file(run_id, session["session_id"])) - delta = raw_session.get("state", {}).get("relation_delta", {}).get("林黛玉_贾宝玉", {}) + delta = raw_session.get("state", {}).get("relations", {}).get("delta", {}).get("林黛玉_贾宝玉", {}) self.assertEqual(delta.get("trust"), 2) self.assertEqual(delta.get("affection"), 1) self.assertIn("明显更松", str(delta.get("last_event", ""))) - snapshot = raw_session.get("state", {}).get("character_snapshots", {}).get("贾宝玉", {}) + snapshot = raw_session.get("state", {}).get("characters", {}).get("snapshots", {}).get("贾宝玉", {}) self.assertEqual(snapshot.get("interaction_state"), "softening") def test_dialogue_event_signals_capture_scene_and_inline_action_categories(self): @@ -3271,9 +3343,11 @@ def test_dialogue_event_signals_capture_scene_and_inline_action_categories(self) ) raw_session = service.dialogue._read_json(service.dialogue._session_file(run_id, session["session_id"])) - event_signals = dict(raw_session.get("state", {}).get("event_signals", {}) or {}) + event_signals = dict(raw_session.get("state", {}).get("signals", {}) or {}) recent = list(event_signals.get("recent", []) or []) kinds = {str(item.get("kind", "")).strip() for item in recent} + overview = dict(service.dialogue._serialize_session(run_id, raw_session).get("runtime_state_overview", {}) or {}) + event_rows = list(overview.get("event_rows", []) or []) self.assertIn("time_change", kinds) self.assertIn("environment_change", kinds) @@ -3281,6 +3355,7 @@ def test_dialogue_event_signals_capture_scene_and_inline_action_categories(self) self.assertIn("cast_exit", kinds) self.assertIn("micro_action", kinds) self.assertIn("atmosphere_shift", kinds) + self.assertTrue(event_rows) micro_action = next(item for item in recent if str(item.get("kind", "")).strip() == "micro_action") self.assertEqual(micro_action.get("actor"), "林黛玉") @@ -5827,6 +5902,9 @@ def test_ingest_turn_updates_scene_progress_and_future_active_participants(self) self.assertEqual(updated["scene_progress"]["time_hint"], "夜里") self.assertEqual(updated["scene_progress"]["present_participants"], ["林黛玉", "贾宝玉"]) self.assertEqual(updated["scene_progress"]["offstage_participants"], ["薛宝钗"]) + self.assertTrue(updated["scene_progress"]["atmosphere_summary"]) + self.assertGreater(updated["scene_progress"]["beat_maturity"], 0) + self.assertTrue(updated["scene_progress"]["world_tension_summary"]) self.assertIn("夜里", updated["session_memory_summary"]["scene_frame"]) self.assertIn("薛宝钗", updated["session_memory_summary"]["cast"]) @@ -5901,6 +5979,8 @@ def test_scene_progress_can_flag_natural_scene_shift_after_longer_turn(self): self.assertTrue(updated["scene_progress"]["should_offer_scene_shift"]) self.assertIn("下一幕", updated["scene_progress"]["scene_shift_reason"]) + self.assertGreaterEqual(updated["scene_progress"]["beat_maturity"], 70) + self.assertTrue(updated["scene_progress"]["world_tension_summary"]) self.assertIn("转场提示", updated["session_memory_summary"]["scene_frame"]) From 0f049a867477ea5cb1cfe7594415c4789c64638a Mon Sep 17 00:00:00 2001 From: wkbin Date: Thu, 14 May 2026 12:38:30 +0800 Subject: [PATCH 02/11] feat: surface dialogue runtime state in chat ui --- src/web/static/fragments/main-shell.html | 33 ++++ src/web/static/js/dialogue.js | 226 +++++++++++++++++++++++ src/web/static/styles/dialogue.css | 119 ++++++++++++ src/web/static/styles/responsive.css | 8 + 4 files changed, 386 insertions(+) diff --git a/src/web/static/fragments/main-shell.html b/src/web/static/fragments/main-shell.html index f526a4a..ea78147 100644 --- a/src/web/static/fragments/main-shell.html +++ b/src/web/static/fragments/main-shell.html @@ -36,6 +36,39 @@ +
场景回顾 diff --git a/src/web/static/js/dialogue.js b/src/web/static/js/dialogue.js index ec0785c..467f3c5 100644 --- a/src/web/static/js/dialogue.js +++ b/src/web/static/js/dialogue.js @@ -209,6 +209,226 @@ function buildDialogueMemorySnapshot(session) { }; } +function renderDialogueStatePills(root, items) { + if (!root) return; + root.innerHTML = ""; + (Array.isArray(items) ? items : []).forEach((item) => { + const text = String(item?.text || "").trim(); + if (!text) return; + const chip = document.createElement("span"); + chip.className = `dialogue-state-pill${item?.faint ? " is-faint" : ""}`; + chip.textContent = text; + root.appendChild(chip); + }); +} + +function renderDialogueStateChipList(root, items, emptyText = "暂时还没有明显变化。") { + if (!root) return; + root.innerHTML = ""; + const values = Array.isArray(items) ? items.filter(Boolean) : []; + if (!values.length) { + const chip = document.createElement("span"); + chip.className = "dialogue-state-chip is-faint"; + chip.textContent = emptyText; + root.appendChild(chip); + return; + } + values.forEach((value) => { + const chip = document.createElement("span"); + chip.className = "dialogue-state-chip"; + chip.textContent = String(value || "").trim(); + root.appendChild(chip); + }); +} + +function renderDialogueStateMiniList(root, items, emptyText = "这一栏还没有收出明显变化。") { + if (!root) return; + root.innerHTML = ""; + const rows = Array.isArray(items) ? items.filter(Boolean) : []; + if (!rows.length) { + const item = document.createElement("div"); + item.className = "dialogue-state-mini-item"; + const copy = document.createElement("p"); + copy.textContent = emptyText; + item.appendChild(copy); + root.appendChild(item); + return; + } + rows.forEach((row) => { + const item = document.createElement("div"); + item.className = "dialogue-state-mini-item"; + const title = document.createElement("strong"); + title.textContent = String(row?.title || "").trim() || "未命名"; + item.appendChild(title); + const copy = document.createElement("p"); + copy.textContent = String(row?.copy || "").trim() || emptyText; + item.appendChild(copy); + root.appendChild(item); + }); +} + +function buildDialogueStateSnapshot(session) { + const overview = session?.runtime_state_overview || null; + if (overview && typeof overview === "object") { + return { + present: Array.isArray(overview.present) ? overview.present.filter(Boolean) : [], + offstage: Array.isArray(overview.offstage) ? overview.offstage.filter(Boolean) : [], + pills: Array.isArray(overview.pills) ? overview.pills.filter((item) => String(item?.text || "").trim()) : [], + tension: trimInlineMessage(String(overview.tension || "").trim()) || "这一拍的情绪和冲突会收在这里。", + characterRows: Array.isArray(overview.character_rows) ? overview.character_rows : [], + relationRows: Array.isArray(overview.relation_rows) ? overview.relation_rows : [], + eventRows: Array.isArray(overview.event_rows) ? overview.event_rows : [], + statusLine: trimInlineMessage(String(overview.status_line || "").trim()), + nextHint: trimInlineMessage(String(overview.next_hint || "").trim()), + }; + } + const state = session?.state || {}; + const scene = state?.scene || {}; + const presence = state?.presence || {}; + const progression = state?.progression || {}; + const progress = session?.scene_progress || {}; + const present = Array.isArray(progress?.present_participants) ? progress.present_participants : (presence?.present_participants || []); + const offstage = Array.isArray(progress?.offstage_participants) ? progress.offstage_participants : (presence?.offstage_participants || []); + const location = String(progress?.location || scene?.location || "").trim(); + const timeHint = String(progress?.time_hint || scene?.time_hint || "").trim(); + const atmosphere = trimInlineMessage(String(progress?.atmosphere_summary || scene?.atmosphere_summary || "").trim()); + const beatMaturity = Number(progress?.beat_maturity || progression?.beat_maturity || 0) || 0; + const canShift = Boolean(progress?.should_offer_scene_shift ?? progression?.should_offer_scene_shift); + const shiftReason = trimInlineMessage(String(progress?.scene_shift_reason || progression?.scene_shift_reason || "").trim()); + const tension = trimInlineMessage( + String(progress?.world_tension_summary || progression?.world_tension_summary || session?.session_memory_summary?.world || "").trim() + ) || "这一拍的情绪和冲突会收在这里。"; + const characterSnapshots = session?.character_snapshots || state?.characters?.snapshots || {}; + const relationDelta = session?.relation_delta || state?.relations?.delta || {}; + + const pills = []; + if (location) pills.push({ text: `地点 · ${location}` }); + if (timeHint) pills.push({ text: `时间 · ${timeHint}` }); + if (atmosphere) pills.push({ text: `氛围 · ${atmosphere}` }); + if (beatMaturity > 0) pills.push({ text: `推进 ${Math.max(0, Math.min(100, Math.round(beatMaturity)))}/100` }); + if (canShift) pills.push({ text: shiftReason ? `可转场 · ${shiftReason}` : "这一拍可以顺势转场" }); + + const characterRows = Object.entries(characterSnapshots) + .map(([name, snapshot]) => { + const item = snapshot || {}; + const parts = []; + const presentState = String(item?.present_state || "").trim(); + if (presentState === "onstage") parts.push("在场"); + if (presentState === "offstage") parts.push("离场"); + if (item?.mood) parts.push(String(item.mood).trim()); + if (item?.interaction_state) parts.push(String(item.interaction_state).trim()); + if (item?.focus) parts.push(`看向 ${String(item.focus).trim()}`); + if (item?.scene_location && String(item.scene_location).trim() !== location) { + parts.push(String(item.scene_location).trim()); + } + return { + title: String(name || "").trim(), + copy: parts.filter(Boolean).join(" · "), + weight: presentState === "onstage" ? 0 : 1, + }; + }) + .filter((item) => item.title) + .sort((left, right) => { + if (left.weight !== right.weight) return left.weight - right.weight; + return left.title.localeCompare(right.title, "zh-Hans-CN"); + }) + .slice(0, 4) + .map(({ title, copy }) => ({ title, copy: copy || "这一拍还没有额外漂移。" })); + + const relationRows = Object.entries(relationDelta) + .map(([pairKey, delta]) => { + const item = delta || {}; + const metrics = []; + [["trust", "信任"], ["affection", "好感"], ["hostility", "敌意"], ["ambiguity", "摇摆"]].forEach(([field, label]) => { + const value = Number(item?.[field] || 0) || 0; + if (!value) return; + metrics.push(`${label}${value > 0 ? "+" : ""}${value}`); + }); + const lastEvent = trimInlineMessage(String(item?.last_event || "").trim()); + return { + title: String(pairKey || "").trim().replace(/_/g, " · "), + copy: metrics.length ? `${metrics.join(" / ")}${lastEvent ? ` · ${lastEvent}` : ""}` : (lastEvent || "这组关系本局有变化。"), + }; + }) + .filter((item) => item.title) + .slice(0, 3); + + const eventKindLabel = { + scene_transition: "转场", + cast_enter: "入场", + cast_exit: "离场", + atmosphere_shift: "气氛变化", + time_change: "时间推进", + environment_change: "环境变化", + beat_complete: "一拍收束", + relationship_shift: "关系变化", + micro_action: "细微动作", + }; + return { + present: Array.isArray(present) ? present.filter(Boolean) : [], + offstage: Array.isArray(offstage) ? offstage.filter(Boolean) : [], + pills, + tension, + characterRows, + relationRows, + eventRows: Array.isArray(session?.event_signals?.recent) + ? session.event_signals.recent.slice(-4).map((item) => ({ + title: [ + eventKindLabel[String(item?.kind || "").trim()] || String(item?.kind || "").trim(), + String(item?.actor || "").trim(), + String(item?.target || "").trim(), + ].filter(Boolean).join(" · ") || "事件", + copy: trimInlineMessage(String(item?.cue || "").trim()) || "这一拍有了新波动。", + })) + : [], + statusLine: "", + nextHint: "", + }; +} + +function renderDialogueStateOverview(session) { + const root = el("dialogue-state-overview"); + if (!root || !session) return; + const snapshot = buildDialogueStateSnapshot(session); + const hasContent = Boolean( + snapshot.pills.length || snapshot.present.length || snapshot.offstage.length || snapshot.characterRows.length || snapshot.relationRows.length || snapshot.eventRows?.length || snapshot.tension + ); + root.classList.toggle("hidden", !hasContent); + if (!hasContent) return; + renderDialogueStatePills(el("dialogue-state-pills"), snapshot.pills); + renderDialogueStateChipList(el("dialogue-state-present"), snapshot.present, "这会儿还没有明确在场名单。"); + renderDialogueStateChipList(el("dialogue-state-offstage"), snapshot.offstage, "暂时没人明确离场。"); + setText("dialogue-state-tension", snapshot.tension, "这一拍的情绪和冲突会收在这里。"); + renderDialogueStateMiniList(el("dialogue-state-characters"), snapshot.characterRows, "角色快照会在聊出状态差后收进来。"); + renderDialogueStateMiniList(el("dialogue-state-relations"), snapshot.relationRows, "关系要聊出明显变化,才会在这里留下痕迹。"); + renderDialogueStateMiniList(el("dialogue-state-events"), snapshot.eventRows || [], "最近还没有收出更明确的事件波动。"); +} + +function buildDialogueSessionStatusLine(session) { + const snapshot = buildDialogueStateSnapshot(session); + if (snapshot.statusLine) { + return snapshot.statusLine; + } + const bits = []; + const pillTexts = Array.isArray(snapshot.pills) + ? snapshot.pills.map((item) => String(item?.text || "").trim()).filter(Boolean) + : []; + if (pillTexts.length) { + bits.push(pillTexts.slice(0, 3).join(" · ")); + } + if (Array.isArray(snapshot.present) && snapshot.present.length) { + bits.push(`在场:${snapshot.present.slice(0, 3).join("、")}`); + } + if (Array.isArray(snapshot.offstage) && snapshot.offstage.length) { + bits.push(`离场:${snapshot.offstage.slice(0, 2).join("、")}`); + } + const tension = trimInlineMessage(snapshot.tension || ""); + if (tension) { + bits.push(`张力:${tension}`); + } + return bits.filter(Boolean).join(" | "); +} + function renderDialogueMemory(session) { const root = el("dialogue-memory"); if (!root) return; @@ -245,6 +465,7 @@ function renderDialogueMemory(session) { if (body) { body.classList.toggle("hidden", body.parentElement === root); } + renderDialogueStateOverview(session); renderDialogueSceneTimeline(session); if (typeof window.renderDialogueSceneSwitcher === "function") { window.renderDialogueSceneSwitcher(session); @@ -339,6 +560,7 @@ function buildDialogueMemoryClipboardText(session) { `【本局记忆】`, `模式:${snapshot.modeLabel}`, `同席:${participantText}`, + `本局状态:${buildDialogueStateSnapshot(session).pills.map((item) => item.text).join(" / ") || "暂无"}`, `场景回顾:${snapshot.recap}`, `人物动向:${snapshot.cast}`, `关系变化:${snapshot.relation}`, @@ -646,7 +868,11 @@ async function renderDialogueSession(session) { if (typeof renderObserveQuickReplies === "function") { renderObserveQuickReplies(session); } + const statusLine = buildDialogueSessionStatusLine(session); setSessionBadge("对话中"); + if (typeof setStatus === "function") { + setStatus("dialogue-session-status", statusLine || "这一幕已经铺好,你可以继续说下去。"); + } renderDialogueMemory(session); renderDialogueTranscript(session); await loadRecentSessions(); diff --git a/src/web/static/styles/dialogue.css b/src/web/static/styles/dialogue.css index eca6503..5fcb210 100644 --- a/src/web/static/styles/dialogue.css +++ b/src/web/static/styles/dialogue.css @@ -225,6 +225,125 @@ line-height: 1.5; } +.dialogue-state-overview { + display: grid; + gap: 0.5rem; + padding: 0.76rem 0.82rem; + border-radius: 14px; + border: 1px solid rgba(170, 146, 127, 0.12); + background: + linear-gradient(180deg, rgba(255, 252, 248, 0.82), rgba(255, 248, 242, 0.7)); +} + +.dialogue-state-overview-head { + display: grid; + gap: 0.14rem; +} + +.dialogue-state-overview-head strong { + color: var(--ink); + font-size: 0.75rem; + line-height: 1.4; +} + +.dialogue-state-overview-head small { + color: var(--ink-faint); + font-size: 0.67rem; + line-height: 1.5; +} + +.dialogue-state-pills { + display: flex; + flex-wrap: wrap; + gap: 0.34rem; +} + +.dialogue-state-pill, +.dialogue-state-chip { + display: inline-flex; + align-items: center; + min-height: 1.45rem; + padding: 0 0.56rem; + border-radius: 999px; + background: rgba(184, 132, 113, 0.1); + color: var(--accent-strong); + font-size: 0.64rem; + line-height: 1.35; +} + +.dialogue-state-pill.is-faint, +.dialogue-state-chip.is-faint { + background: rgba(122, 104, 90, 0.08); + color: var(--ink-faint); +} + +.dialogue-state-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.46rem; +} + +.dialogue-state-card { + display: grid; + gap: 0.3rem; + padding: 0.62rem 0.7rem; + border-radius: 12px; + border: 1px solid rgba(170, 146, 127, 0.1); + background: rgba(255, 255, 255, 0.62); + min-height: 0; +} + +.dialogue-state-card span { + color: var(--ink-soft); + font-size: 0.67rem; + line-height: 1.35; +} + +.dialogue-state-card p { + margin: 0; + color: var(--ink); + font-size: 0.72rem; + line-height: 1.58; +} + +.dialogue-state-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.32rem; +} + +.dialogue-state-chip-list.is-muted .dialogue-state-chip { + background: rgba(122, 104, 90, 0.08); + color: var(--ink-faint); +} + +.dialogue-state-mini-list { + display: grid; + gap: 0.34rem; +} + +.dialogue-state-mini-item { + display: grid; + gap: 0.08rem; + padding: 0.46rem 0.52rem; + border-radius: 10px; + background: rgba(255, 250, 246, 0.86); + border: 1px solid rgba(170, 146, 127, 0.08); +} + +.dialogue-state-mini-item strong { + color: var(--ink); + font-size: 0.68rem; + line-height: 1.42; +} + +.dialogue-state-mini-item p { + margin: 0; + color: var(--ink-faint); + font-size: 0.65rem; + line-height: 1.5; +} + .dialogue-scene-timeline-list { display: grid; gap: 0.38rem; diff --git a/src/web/static/styles/responsive.css b/src/web/static/styles/responsive.css index 7197dbd..97b9df0 100644 --- a/src/web/static/styles/responsive.css +++ b/src/web/static/styles/responsive.css @@ -136,10 +136,18 @@ grid-template-columns: 1fr; } + .dialogue-memory-modal-scroll .dialogue-state-grid { + grid-template-columns: 1fr; + } + .dialogue-memory-grid { grid-template-columns: 1fr; } + .dialogue-state-grid { + grid-template-columns: 1fr; + } + .chat-detail { min-height: 20rem; } From 38786083163841d29a354ae7cc062d65985dc74b Mon Sep 17 00:00:00 2001 From: wkbin Date: Thu, 14 May 2026 13:01:03 +0800 Subject: [PATCH 03/11] feat: drive observe quick actions from runtime state --- src/web/static/fragments/main-shell.html | 4 + src/web/static/js/composer-vue-island.js | 14 ++++ src/web/static/js/main.js | 102 ++++++++++++++++++++++- src/web/static/styles/dialogue.css | 24 ++++++ 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/web/static/fragments/main-shell.html b/src/web/static/fragments/main-shell.html index ea78147..b8003df 100644 --- a/src/web/static/fragments/main-shell.html +++ b/src/web/static/fragments/main-shell.html @@ -110,6 +110,10 @@
+
diff --git a/src/web/static/js/composer-vue-island.js b/src/web/static/js/composer-vue-island.js index be1ea41..791016e 100644 --- a/src/web/static/js/composer-vue-island.js +++ b/src/web/static/js/composer-vue-island.js @@ -58,6 +58,7 @@ ); const placeholder = computed(() => String(composer.value.placeholder || "")); const quickReplies = computed(() => (Array.isArray(composer.value.quickReplies) ? composer.value.quickReplies : [])); + const quickHint = computed(() => String(composer.value.quickHint || "").trim()); const disabled = computed(() => Boolean(composer.value.disabled)); const suggestHidden = computed(() => Boolean(composer.value.suggestHidden) || mode.value === "observe"); const suggestDisabled = computed(() => Boolean(composer.value.suggestDisabled)); @@ -114,6 +115,7 @@ draftKind, handleEnter, placeholder, + quickHint, quickReplies, quickReply, send, @@ -140,6 +142,18 @@
+
+

顺手往下推:{{ quickHint }}

+ +
+
+
diff --git a/src/web/static/js/main.js b/src/web/static/js/main.js index f3092d4..4970cdd 100644 --- a/src/web/static/js/main.js +++ b/src/web/static/js/main.js @@ -930,6 +930,9 @@ function renderDialogueSceneSwitcher(session = currentDialogueSession) { const select = el("dialogue-live-scene-card"); const status = el("dialogue-live-scene-status"); const recommendButton = el("dialogue-live-scene-recommend"); + const shiftHint = el("dialogue-live-scene-shift-hint"); + const shiftCopy = el("dialogue-live-scene-shift-copy"); + const shiftRecommendButton = el("dialogue-live-scene-shift-recommend"); if (!shell || !select) return; const hasSession = Boolean(session?.session_id) && Boolean(currentRunId); shell.classList.toggle("hidden", !hasSession); @@ -937,11 +940,19 @@ function renderDialogueSceneSwitcher(session = currentDialogueSession) { select.innerHTML = ""; if (status) status.textContent = ""; if (recommendButton) recommendButton.disabled = true; + if (shiftHint) shiftHint.classList.add("hidden"); + if (shiftCopy) shiftCopy.textContent = ""; + if (shiftRecommendButton) shiftRecommendButton.disabled = true; renderDialogueSceneChainSuggestions([], ""); return; } if (recommendButton) recommendButton.disabled = sceneCards.length < 2; + if (shiftRecommendButton) shiftRecommendButton.disabled = sceneCards.length < 2; const currentSceneId = String(session?.session_card?.scene_card_id || "").trim(); + const overview = session?.runtime_state_overview || {}; + const shouldShift = Boolean(overview?.should_offer_scene_shift); + const shiftReason = String(overview?.scene_shift_reason || "").trim(); + const nextHint = String(overview?.next_hint || "").trim(); const previous = select.value || currentSceneId; select.innerHTML = ""; const blank = document.createElement("option"); @@ -961,8 +972,20 @@ function renderDialogueSceneSwitcher(session = currentDialogueSession) { } else { select.value = currentSceneId; } + if (shiftHint) { + shiftHint.classList.toggle("hidden", !shouldShift); + } + if (shiftCopy) { + shiftCopy.textContent = shouldShift + ? (shiftReason || nextHint || "这一拍差不多收住了,可以顺势切到下一幕。") + : ""; + } if (status && !String(status.textContent || "").trim()) { - status.textContent = currentSceneId ? "当前会话已经挂载场景卡,你可以随时切到另一幕。" : "当前会话还没挂场景卡,也可以直接在这里接入一张。"; + if (shouldShift) { + status.textContent = "这一拍已经接近收束,可以顺势切一张场景卡。"; + } else { + status.textContent = currentSceneId ? "当前会话已经挂载场景卡,你可以随时切到另一幕。" : "当前会话还没挂场景卡,也可以直接在这里接入一张。"; + } } renderDialogueSceneChainSuggestions(currentDialogueSceneChainSuggestions, session?.session_id || ""); } @@ -2872,6 +2895,7 @@ function bindEvents() { bind("delete-opening-preset-button", "click", handleDeleteOpeningPreset); bind("recommend-scene-card-button", "click", handleRecommendSceneCard); bind("dialogue-live-scene-recommend", "click", handleRecommendDialogueSceneCard); + bind("dialogue-live-scene-shift-recommend", "click", handleRecommendDialogueSceneCard); bind("dialogue-live-scene-apply", "click", handleApplyDialogueSceneCard); bind("create-scene-card-button", "click", handleOpenNewSceneCard); bind("edit-scene-card-button", "click", handleEditCurrentSceneCard); diff --git a/src/web/static/styles/dialogue.css b/src/web/static/styles/dialogue.css index 7093407..23e7c4f 100644 --- a/src/web/static/styles/dialogue.css +++ b/src/web/static/styles/dialogue.css @@ -140,6 +140,28 @@ background: rgba(255, 255, 255, 0.62); } +.dialogue-live-scene-shift-hint { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + padding: 0.62rem 0.7rem; + border-radius: 12px; + border: 1px solid rgba(194, 157, 138, 0.18); + background: linear-gradient(180deg, rgba(255, 249, 244, 0.96), rgba(249, 238, 229, 0.9)); +} + +.dialogue-live-scene-shift-copy { + margin: 0; + color: var(--ink-soft); + font-size: 0.7rem; + line-height: 1.55; +} + +#dialogue-live-scene-shift-recommend { + flex: 0 0 auto; +} + .dialogue-scene-chain-suggestions { display: grid; gap: 0.42rem; From 8c0610167254b336a8dc28017753fe0323dfa4fe Mon Sep 17 00:00:00 2001 From: wkbin Date: Thu, 14 May 2026 13:19:03 +0800 Subject: [PATCH 06/11] feat: steer observe suggestions with scene progress --- src/web/chat/helpers.py | 4 ++ src/web/chat/service.py | 74 +++++++++++++++++++++++----- tests/test_web_app.py | 105 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) diff --git a/src/web/chat/helpers.py b/src/web/chat/helpers.py index c47b3ca..e13d905 100644 --- a/src/web/chat/helpers.py +++ b/src/web/chat/helpers.py @@ -420,6 +420,7 @@ def build_dialogue_suggestion_llm_messages( relation_excerpt = str(payload.get("relation_context", {}).get("relations_excerpt", "")).strip() history = payload.get("history", []) memory_context = dict(payload.get("memory_context", {}) or {}) + scene_progress = dict(payload.get("scene_progress", {}) or memory_context.get("scene_progress", {}) or {}) instructions = dict(payload.get("instructions", {}) or {}) host_action = dict(payload.get("host_action", {}) or {}) scene_card = dict(payload.get("scene_card", {}) or {}) @@ -439,6 +440,8 @@ def build_dialogue_suggestion_llm_messages( "如果上下文允许多种接法,优先选更符合 user_persona 的那一种,而不是只做一个泛用接话。", "如果 mode=act,就按 controlled character 的 persona profile、speech_style、temperament 和典型说话习惯来写。", "如果 mode=observe,就把这句话写成推动剧情的场景提示:让局势往前走,而不是复述、总结或劝说。", + "如果 scene_progress 显示这一拍已经成熟、适合转场,就优先写成自然的转场推进;如果还没到转场时机,就优先续当前这一拍的动作、情绪或张力。", + "offstage_participants 里的人不要被你无端写回来,除非这句提示本身就在明确推动他们重新入场。", "如果 scene_card 存在,优先服从它给出的地点、气氛、开场局面、明面目标、暗线张力与推进方向。", "只输出一句最终可发送的成品台词,不要解释上下文,不要总结历史,不要提供建议理由,不要写“作为/当前场景/我们可以/你可以/建议/回复:”这类分析话术。", "不要分段,不要项目符号,不要加引号,不要加说话人标签。", @@ -454,6 +457,7 @@ def build_dialogue_suggestion_llm_messages( "speaker": str(input_block.get("speaker", "")).strip(), "seed_text": str(input_block.get("message", "")).strip(), "scene_card": scene_card, + "scene_progress": scene_progress, "memory_context": memory_context, "user_persona": user_persona, "participants": participants, diff --git a/src/web/chat/service.py b/src/web/chat/service.py index 3f7e1c2..0424fea 100644 --- a/src/web/chat/service.py +++ b/src/web/chat/service.py @@ -551,7 +551,13 @@ def build_suggestion_payload( speaker = str(payload.get("input", {}).get("speaker", "")).strip() participants = list(payload.get("input", {}).get("participants", [])) payload["kind"] = "zaomeng_dialogue_suggestion" - payload["user_persona"] = self._build_user_suggestion_persona(mode, session, payload.get("persona_contexts", [])) + scene_progress = dict(payload.get("scene_progress", {}) or {}) + payload["user_persona"] = self._build_user_suggestion_persona( + mode, + session, + payload.get("persona_contexts", []), + scene_progress=scene_progress, + ) payload["instructions"] = { "mode": mode, "generation_goal": "Draft one short, natural, directly sendable next user line that fits the current scene, relationships, and persona voices.", @@ -563,7 +569,12 @@ def build_suggestion_payload( "expected_output": {"suggestion": "一句可直接发送的话"}, "output_rule": "Keep it short, in-scene, directly sendable, and never explanatory.", } - payload["host_prompt_brief"] = self._host_suggestion_prompt_brief(mode, speaker, participants) + payload["host_prompt_brief"] = self._host_suggestion_prompt_brief( + mode, + speaker, + participants, + scene_progress=scene_progress, + ) payload["updated_at"] = _utc_now() return payload @@ -924,8 +935,11 @@ def _build_user_suggestion_persona( mode: str, session: dict[str, Any], persona_contexts: list[dict[str, Any]], + *, + scene_progress: dict[str, Any] | None = None, ) -> dict[str, Any]: scene_card = dict(session.get("scene_card", {}) or {}) + state = dict(scene_progress or {}) if mode == "act": controlled = str(session.get("controlled_character", "")).strip() matched = next( @@ -951,20 +965,39 @@ def _build_user_suggestion_persona( "profile": dict(card), "scene_card": scene_card, } + preferred_moves = [ + "introduce a new action", + "add a small interruption", + "surface a hidden tension", + "shift the emotional temperature", + "make someone notice something important", + ] + offstage = [str(item).strip() for item in list(state.get("offstage_participants", []) or []) if str(item).strip()] + if bool(state.get("should_offer_scene_shift", False)): + preferred_moves.extend( + [ + "turn the scene into its next beat naturally", + "advance time or location without sounding abrupt", + ] + ) + elif offstage: + preferred_moves.append("briefly cut to an offstage thread only if the text explicitly motivates it") return { "mode": "observe", "speaker": "User", "source": "observer_hint", - "must_follow": "Write as a scene observer giving a short in-world nudge that actively moves the scene, rather than speaking as a cast member.", + "must_follow": ( + "Write as a scene observer giving a short in-world nudge that actively moves the scene, " + "rather than speaking as a cast member. Respect the current scene progress, presence state, " + "and whether this beat should continue or naturally turn into the next one." + ), "profile": { "goal": "push_plot_forward", - "preferred_moves": [ - "introduce a new action", - "add a small interruption", - "surface a hidden tension", - "shift the emotional temperature", - "make someone notice something important", - ], + "preferred_moves": preferred_moves, + "scene_shift_reason": str(state.get("scene_shift_reason", "")).strip(), + "time_hint": str(state.get("time_hint", "")).strip(), + "location": str(state.get("location", "")).strip(), + "world_tension_summary": str(state.get("world_tension_summary", "")).strip(), }, "scene_card": scene_card, } @@ -993,11 +1026,30 @@ def _host_prompt_brief(mode: str, speaker: str, participants: list[str]) -> str: return f"The user is observing. Let {', '.join(participants)} continue the scene in character and keep the chosen scene moving." @staticmethod - def _host_suggestion_prompt_brief(mode: str, speaker: str, participants: list[str]) -> str: + def _host_suggestion_prompt_brief( + mode: str, + speaker: str, + participants: list[str], + *, + scene_progress: dict[str, Any] | None = None, + ) -> str: + state = dict(scene_progress or {}) if mode == "act": return f"Help the user speak as {speaker} with one believable next line." if mode == "insert": return f"Help the user speak as {speaker} inside the current scene with one natural next line." + shift_reason = str(state.get("scene_shift_reason", "")).strip() + if bool(state.get("should_offer_scene_shift", False)): + return ( + f"Help the user guide {', '.join(participants)} with one short prompt that naturally turns this scene into its next beat. " + f"Current transition pressure: {shift_reason or 'the current beat already feels complete'}." + ) + tension = str(state.get("world_tension_summary", "")).strip() + if tension: + return ( + f"Help the user guide {', '.join(participants)} with one short prompt that clearly pushes the scene forward. " + f"Carry this tension: {tension}." + ) return f"Help the user guide {', '.join(participants)} with one short prompt that clearly pushes the scene into its next beat." @staticmethod diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 0ed3e3e..4bcde21 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -3881,6 +3881,111 @@ def test_build_suggestion_payload_uses_plot_push_observer_hint_in_observe_mode(s self.assertIn("introduce a new action", payload["user_persona"]["profile"]["preferred_moves"]) self.assertIn("pushes the plot forward", payload["instructions"]["response_style"]) + def test_build_suggestion_payload_observe_mode_carries_scene_shift_pressure(self): + with tempfile.TemporaryDirectory() as tmp: + service = WebRunService(tmp) + service.save_model_settings( + provider="openai-compatible", + model="deepseek-chat", + base_url="https://example.com/v1", + api_key="sk-test", + ) + run = service.create_run( + novel_name="hongloumeng.txt", + novel_content_base64=base64.b64encode("林黛玉见了贾宝玉。".encode("utf-8")).decode("ascii"), + characters=["林黛玉", "贾宝玉"], + ) + run_id = run["run_id"] + for name in ("林黛玉", "贾宝玉"): + service.ingest_character_result( + run_id, + character=name, + content_base64=base64.b64encode( + f"- name: {name}\n- novel_id: hongloumeng\n- core_identity: 人物\n".encode("utf-8") + ).decode("ascii"), + ) + manifest = service._require_manifest(run_id) + session = service.dialogue.create_session( + manifest, + mode="observe", + participants=["林黛玉", "贾宝玉"], + controlled_character="", + self_profile={}, + ) + service.dialogue.update_scene_progress_state( + run_id, + session["session_id"], + { + "location": "回廊", + "time_hint": "夜深", + "beat_maturity": 85, + "should_offer_scene_shift": True, + "scene_shift_reason": "雨势更大,再站在回廊里已经接不下去了", + "world_tension_summary": "两个人都知道下一句就该把局面带进新的地方", + }, + ) + + payload = service.dialogue.build_suggestion_payload( + manifest, + session_id=session["session_id"], + seed_text="", + ) + + self.assertIn("turn the scene into its next beat naturally", payload["user_persona"]["profile"]["preferred_moves"]) + self.assertEqual(payload["user_persona"]["profile"]["scene_shift_reason"], "雨势更大,再站在回廊里已经接不下去了") + self.assertIn("naturally turns this scene into its next beat", payload["host_prompt_brief"]) + self.assertIn("Current transition pressure", payload["host_prompt_brief"]) + + def test_build_dialogue_suggestion_messages_use_scene_progress_for_observe_mode(self): + payload = { + "mode": "observe", + "input": { + "speaker": "User", + "message": "", + "participants": ["林黛玉", "贾宝玉"], + }, + "persona_contexts": [], + "user_persona": { + "mode": "observe", + "speaker": "User", + "source": "observer_hint", + "must_follow": "Write as a scene observer giving a short in-world nudge.", + "profile": { + "goal": "push_plot_forward", + "preferred_moves": ["turn the scene into its next beat naturally"], + }, + }, + "relation_context": {"relations_excerpt": ""}, + "history": [], + "memory_context": {"scene_progress": {"offstage_participants": ["薛宝钗"]}}, + "scene_progress": { + "time_hint": "夜深", + "location": "回廊", + "offstage_participants": ["薛宝钗"], + "should_offer_scene_shift": True, + "scene_shift_reason": "这幕已经够满,可以顺势切到花厅", + }, + "instructions": { + "generation_goal": "Draft one short, natural, directly sendable next user line that fits the current scene, relationships, and persona voices.", + "mode_rule": "Draft the user's next line as a short scene-steering utterance.", + "speaker_rule": "Treat the user message as a scene steering hint.", + "response_style": "Prefer one short scene-driving prompt that pushes the plot forward immediately.", + "scene_rule": "Keep the scene anchored.", + }, + "host_action": { + "expected_output": {"suggestion": "一句可直接发送的话"}, + "output_rule": "Keep it short, in-scene, directly sendable, and never explanatory.", + }, + "host_prompt_brief": "Help the user guide 林黛玉, 贾宝玉 with one short prompt that naturally turns this scene into its next beat.", + "scene_card": {}, + } + + messages = WebRunService._build_dialogue_suggestion_llm_messages(payload) + + self.assertIn("scene_progress", messages[1]["content"]) + self.assertIn("这一拍已经成熟、适合转场", messages[0]["content"]) + self.assertIn("offstage_participants", messages[0]["content"]) + @unittest.skipIf(TestClient is None or create_app is None, "fastapi test dependencies unavailable") class WebAppRouteTests(unittest.TestCase): From a282132fdd08386ab5d8cfab0ac2097c11a3b54a Mon Sep 17 00:00:00 2001 From: wkbin Date: Thu, 14 May 2026 13:21:38 +0800 Subject: [PATCH 07/11] feat: show scene context in scene switcher --- src/web/static/fragments/main-shell.html | 1 + src/web/static/js/main.js | 18 ++++++++++++++++++ src/web/static/styles/dialogue.css | 7 +++++++ 3 files changed, 26 insertions(+) diff --git a/src/web/static/fragments/main-shell.html b/src/web/static/fragments/main-shell.html index d76ef0d..f260275 100644 --- a/src/web/static/fragments/main-shell.html +++ b/src/web/static/fragments/main-shell.html @@ -26,6 +26,7 @@ 中途转场 切一张场景卡,再补一句转场提示,就能把这一幕顺手推过去。
+