返回 JoyAI-Echo
websocket.py
1 """WebSocket server channel: nanobot acts as a WebSocket server and serves connected clients."""
2
3 from __future__ import annotations
4
5 import asyncio
6 import base64
7 import binascii
8 import email.utils
9 import hashlib
10 import hmac
11 import http
12 import json
13 import mimetypes
14 import os
15 import re
16 import secrets
17 import shutil
18 import ssl
19 import subprocess
20 import tempfile
21 import time
22 import urllib.error
23 import urllib.request
24 import uuid
25 from datetime import datetime, timezone
26 from pathlib import Path
27 from typing import TYPE_CHECKING, Any, Callable, Self
28 from urllib.parse import unquote, urlparse
29
30 from loguru import logger
31 from pydantic import Field, field_validator, model_validator
32 from websockets.asyncio.server import ServerConnection, serve
33 from websockets.datastructures import Headers
34 from websockets.exceptions import ConnectionClosed
35 from websockets.http11 import Request as WsRequest
36 from websockets.http11 import Response
37
38 from nanobot.agent.tools.director import (
39 _WORKFLOW_INJECTED_EVENT,
40 WORKFLOW_GATE_BYPASS,
41 GenerateEchoShotTool,
42 MergeShotTool,
43 ReviewShotTool,
44 SetShotReferencesTool,
45 _caption_language_validation_error,
46 _shot_key,
47 _story_profile_validation_error,
48 resolve_echo_duration_seconds,
49 rewrite_prompt_for_i2v,
50 sync_shot_echo_duration,
51 )
52 from nanobot.bus.events import InboundMessage, OutboundMessage
53 from nanobot.bus.queue import MessageBus
54 from nanobot.channels.base import BaseChannel
55 from nanobot.channels.webui_sessions import (
56 is_legacy_two_part_webui_session_key,
57 webui_session_key,
58 webui_wire_chat_id,
59 )
60 from nanobot.config.paths import get_media_dir
61 from nanobot.config.schema import Base, ToolsConfig
62 from nanobot.director.memory_coordinator import (
63 _resolve_media_binary,
64 download_video,
65 extract_video_frame,
66 select_manual_memory_frame,
67 )
68 from nanobot.director.memory_review import (
69 MemoryReviewConflict,
70 approve_memory_review,
71 reselect_memory_review,
72 )
73 from nanobot.integrations.echo_admission import (
74 UNAVAILABLE_MESSAGE,
75 EchoAdmissionController,
76 EchoGeneratorBusyError,
77 EchoGeneratorUnavailableError,
78 is_connection_refused,
79 )
80 from nanobot.security.http_download import HttpDownloadError, download_http_bytes
81 from nanobot.security.url_validator import (
82 DownloadUrlPolicy,
83 UrlValidationError,
84 configure_download_policy,
85 validate_external_url,
86 )
87 from nanobot.session.auto_generate import (
88 DEFAULT_AUTO_GENERATE_DURATION_SEC,
89 apply_auto_generate,
90 get_auto_generate,
91 locked_shot_count_from_goal,
92 locked_shot_count_from_state,
93 resolve_auto_generate_from_wire,
94 shot_count_for_auto_generate,
95 )
96 from nanobot.session.reference_image import (
97 apply_story_direction_answer,
98 is_blocked_local_url,
99 is_reference_image_locked,
100 mark_reference_image_needs_story_rewrite,
101 normalize_reference_image,
102 reference_image_present,
103 reference_image_url,
104 story_rewrite_suppressed,
105 )
106 from nanobot.session.source import apply_source, normalize_source
107 from nanobot.storage.files import (
108 configured_file_publisher,
109 resolve_local_asset_path,
110 )
111 from nanobot.utils.helpers import write_json_atomic
112 from nanobot.utils.media_decode import (
113 FileSizeExceeded,
114 save_base64_data_url,
115 )
116
117 if TYPE_CHECKING:
118 from nanobot.providers.base import LLMProvider
119 from nanobot.session.manager import SessionManager
120
121
122 def _strip_trailing_slash(path: str) -> str:
123 if len(path) > 1 and path.endswith("/"):
124 return path.rstrip("/")
125 return path or "/"
126
127
128 def _normalize_config_path(path: str) -> str:
129 return _strip_trailing_slash(path)
130
131
132 _CARD_ENTER_SHOT_POLISH = "进入逐镜打磨"
133 _CARD_CONFIRM_AUTO_GENERATE = "确认并一键成片"
134
135
136 class WebSocketConfig(Base):
137 """WebSocket server channel configuration.
138
139 Clients connect with URLs like ``ws://{host}:{port}{path}?client_id=...&token=...``.
140 - ``client_id``: Used for ``allow_from`` authorization; if omitted, a value is generated and logged.
141 - ``token``: If non-empty, the ``token`` query param may match this static secret; short-lived tokens
142 from ``token_issue_path`` are also accepted.
143 - ``token_issue_path``: If non-empty, **GET** (HTTP/1.1) to this path returns JSON
144 ``{"token": "...", "expires_in": <seconds>}``; use ``?token=...`` when opening the WebSocket.
145 Must differ from ``path`` (the WS upgrade path). If the client runs in the **same process** as
146 nanobot and shares the asyncio loop, use a thread or async HTTP client for GET—do not call
147 blocking ``urllib`` or synchronous ``httpx`` from inside a coroutine.
148 - ``token_issue_secret``: If non-empty, token requests must send ``Authorization: Bearer <secret>`` or
149 ``X-Nanobot-Auth: <secret>``.
150 - ``websocket_requires_token``: If True, the handshake must include a valid token (static or issued and not expired).
151 - Each connection has its own session: a unique ``chat_id`` maps to the agent session internally.
152 - ``media`` field in outbound messages contains local filesystem paths; remote clients need a
153 shared filesystem or an HTTP file server to access these files.
154 """
155
156 enabled: bool = False
157 host: str = "127.0.0.1"
158 port: int = 8765
159 path: str = "/"
160 token: str = ""
161 token_issue_path: str = ""
162 token_issue_secret: str = ""
163 token_ttl_s: int = Field(default=300, ge=30, le=86_400)
164 websocket_requires_token: bool = False
165 allow_from: list[str] = Field(default_factory=lambda: ["*"])
166 streaming: bool = True
167 # Default 36 MB, upper 40 MB: supports up to 4 images at ~6 MB each after
168 # client-side Worker normalization (see webui Composer). 4 × 6 MB × 1.37
169 # (base64 overhead) + envelope framing stays under 36 MB; the 40 MB ceiling
170 # leaves a small margin for sender slop without opening a DoS avenue.
171 max_message_bytes: int = Field(default=37_748_736, ge=1024, le=41_943_040)
172 ping_interval_s: float = Field(default=20.0, ge=5.0, le=300.0)
173 ping_timeout_s: float = Field(default=20.0, ge=5.0, le=300.0)
174 ssl_certfile: str = ""
175 ssl_keyfile: str = ""
176 # Workplace final-video proxy download (SSRF + size cap).
177 download_max_bytes: int = Field(default=104_857_600, ge=1024, le=1_073_741_824)
178 download_timeout_s: int = Field(default=60, ge=5, le=600)
179 download_allowed_domain_suffixes: list[str] = Field(
180 default_factory=list
181 )
182 download_trusted_internal_domains: list[str] = Field(default_factory=list)
183
184 @field_validator("path")
185 @classmethod
186 def path_must_start_with_slash(cls, value: str) -> str:
187 if not value.startswith("/"):
188 raise ValueError('path must start with "/"')
189 return _normalize_config_path(value)
190
191 @field_validator("token_issue_path")
192 @classmethod
193 def token_issue_path_format(cls, value: str) -> str:
194 value = value.strip()
195 if not value:
196 return ""
197 if not value.startswith("/"):
198 raise ValueError('token_issue_path must start with "/"')
199 return _normalize_config_path(value)
200
201 @model_validator(mode="after")
202 def token_issue_path_differs_from_ws_path(self) -> Self:
203 if not self.token_issue_path:
204 return self
205 if _normalize_config_path(self.token_issue_path) == _normalize_config_path(self.path):
206 raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
207 return self
208
209
210 def _http_json_response(data: dict[str, Any] | list, *, status: int = 200) -> Response:
211 body = json.dumps(data, ensure_ascii=False).encode("utf-8")
212 headers = Headers(
213 [
214 ("Date", email.utils.formatdate(usegmt=True)),
215 ("Connection", "close"),
216 ("Content-Length", str(len(body))),
217 ("Content-Type", "application/json; charset=utf-8"),
218 ]
219 )
220 reason = http.HTTPStatus(status).phrase
221 return Response(status, reason, headers, body)
222
223
224 def _read_webui_model_name() -> str | None:
225 """Return the configured default model for readonly webui display."""
226 try:
227 from nanobot.config.loader import load_config
228
229 model = load_config().agents.defaults.model.strip()
230 return model or None
231 except Exception as e:
232 logger.debug("webui bootstrap could not load model name: {}", e)
233 return None
234
235
236 def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
237 """Parse normalized path and query parameters in one pass."""
238 parsed = urlparse("ws://x" + path_with_query)
239 path = _strip_trailing_slash(parsed.path or "/")
240 query: dict[str, list[str]] = {}
241 if parsed.query:
242 for part in parsed.query.split("&"):
243 if not part:
244 continue
245 if "=" in part:
246 key, value = part.split("=", 1)
247 else:
248 key, value = part, ""
249 key = unquote(key, errors="replace")
250 value = unquote(value, errors="replace")
251 query.setdefault(key, []).append(value)
252 return path, query
253
254
255 def _normalize_http_path(path_with_query: str) -> str:
256 """Return the path component (no query string), with trailing slash normalized (root stays ``/``)."""
257 return _parse_request_path(path_with_query)[0]
258
259
260 def _parse_query(path_with_query: str) -> dict[str, list[str]]:
261 return _parse_request_path(path_with_query)[1]
262
263
264 def _query_first(query: dict[str, list[str]], key: str) -> str | None:
265 """Return the first value for *key*, or None."""
266 values = query.get(key)
267 return values[0] if values else None
268
269
270 def _parse_inbound_payload(raw: str) -> str | None:
271 """Parse a client frame into text; return None for empty or unrecognized content."""
272 text = raw.strip()
273 if not text:
274 return None
275 if text.startswith("{"):
276 try:
277 data = json.loads(text)
278 except json.JSONDecodeError:
279 return text
280 if isinstance(data, dict):
281 for key in ("content", "text", "message"):
282 value = data.get(key)
283 if isinstance(value, str) and value.strip():
284 return value
285 return None
286 return None
287 return text
288
289
290 # Accept UUIDs and short scoped keys like "unified:default". Keeps the capability
291 # namespace small enough to rule out path traversal / quote injection tricks.
292 _CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
293
294
295 def _is_valid_chat_id(value: Any) -> bool:
296 return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
297
298
299 def _parse_envelope(raw: str) -> dict[str, Any] | None:
300 """Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
301
302 A frame qualifies when it parses as a JSON object with a string ``type`` field.
303 Legacy frames (plain text, or ``{"content": ...}`` without ``type``) return None;
304 callers should fall back to :func:`_parse_inbound_payload` for those.
305 """
306 text = raw.strip()
307 if not text.startswith("{"):
308 return None
309 try:
310 data = json.loads(text)
311 except json.JSONDecodeError:
312 return None
313 if not isinstance(data, dict):
314 return None
315 t = data.get("type")
316 if not isinstance(t, str):
317 return None
318 return data
319
320
321 # Per-message image limits. The server-side guard is a touch looser than the
322 # client's ``Worker`` normalization target (6 MB) — tolerate client slop, but
323 # still cap total ingress at ``_MAX_IMAGES_PER_MESSAGE * _MAX_IMAGE_BYTES``
324 # which fits comfortably inside ``max_message_bytes``.
325 _MAX_IMAGES_PER_MESSAGE = 4
326 _MAX_IMAGE_BYTES = 8 * 1024 * 1024
327 _MAX_MEMORY_AUDIO_BYTES = 20 * 1024 * 1024
328 _MAX_MEMORY_WORKSPACE_ASSETS = 100
329 _MAX_MEMORY_SLOTS = 7
330 _MEMORY_REFERENCE_TYPES = frozenset({"character", "scene", "style", "object", "other"})
331
332 # Image MIME whitelist — matches the Composer's ``accept`` list. SVG is
333 # explicitly excluded to avoid the XSS surface inside embedded scripts.
334 _IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({
335 "image/png",
336 "image/jpeg",
337 "image/webp",
338 "image/gif",
339 })
340
341 _AUDIO_MIME_ALLOWED: frozenset[str] = frozenset({
342 "audio/aac",
343 "audio/flac",
344 "audio/mp4",
345 "audio/mpeg",
346 "audio/m4a",
347 "audio/ogg",
348 "audio/vnd.wave",
349 "audio/wave",
350 "audio/wav",
351 "audio/webm",
352 "audio/x-m4a",
353 "audio/x-wav",
354 })
355
356 _DATA_URL_MIME_RE = re.compile(r"^data:([^;]+);base64,", re.DOTALL)
357
358
359 def _extract_data_url_mime(url: str) -> str | None:
360 """Return the MIME type of a ``data:<mime>;base64,...`` URL, else ``None``."""
361 if not isinstance(url, str):
362 return None
363 m = _DATA_URL_MIME_RE.match(url)
364 if not m:
365 return None
366 return m.group(1).strip().lower() or None
367
368
369 _LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
370
371 # Matches the legacy chat-id pattern but allows file-system-safe stems too,
372 # so the API can address sessions whose keys came from non-WebSocket channels.
373 _API_KEY_RE = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
374
375
376 def _decode_api_key(raw_key: str) -> str | None:
377 """Decode a percent-encoded API path segment, then validate the result."""
378 key = unquote(raw_key)
379 if _API_KEY_RE.match(key) is None:
380 return None
381 return key
382
383
384 def _is_localhost(connection: Any) -> bool:
385 """Return True if *connection* originated from the loopback interface."""
386 addr = getattr(connection, "remote_address", None)
387 if not addr:
388 return False
389 host = addr[0] if isinstance(addr, tuple) else addr
390 if not isinstance(host, str):
391 return False
392 # ``::ffff:127.0.0.1`` is loopback in IPv6-mapped form.
393 if host.startswith("::ffff:"):
394 host = host[7:]
395 return host in _LOCALHOSTS
396
397
398 def _http_response(
399 body: bytes,
400 *,
401 status: int = 200,
402 content_type: str = "text/plain; charset=utf-8",
403 extra_headers: list[tuple[str, str]] | None = None,
404 ) -> Response:
405 headers = [
406 ("Date", email.utils.formatdate(usegmt=True)),
407 ("Connection", "close"),
408 ("Content-Length", str(len(body))),
409 ("Content-Type", content_type),
410 ]
411 if extra_headers:
412 headers.extend(extra_headers)
413 reason = http.HTTPStatus(status).phrase
414 return Response(status, reason, Headers(headers), body)
415
416
417 def _http_error(status: int, message: str | None = None) -> Response:
418 body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
419 return _http_response(body, status=status)
420
421
422 def _bearer_token(headers: Any) -> str | None:
423 """Pull a Bearer token out of standard or query-style headers."""
424 auth = headers.get("Authorization") or headers.get("authorization")
425 if auth and auth.lower().startswith("bearer "):
426 return auth[7:].strip() or None
427 return None
428
429
430 def _is_websocket_upgrade(request: WsRequest) -> bool:
431 """Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through."""
432 upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade")
433 connection = request.headers.get("Connection") or request.headers.get("connection")
434 if not upgrade or "websocket" not in upgrade.lower():
435 return False
436 if not connection or "upgrade" not in connection.lower():
437 return False
438 return True
439
440
441 def _b64url_encode(data: bytes) -> str:
442 """URL-safe base64 without padding — compact + friendly in URL paths."""
443 return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
444
445
446 def _b64url_decode(s: str) -> bytes:
447 """Reverse of :func:`_b64url_encode`; caller handles ``ValueError``."""
448 pad = "=" * (-len(s) % 4)
449 return base64.urlsafe_b64decode(s + pad)
450
451
452 # Allowed MIME types we actually serve from the media endpoint. Anything
453 # outside this set is degraded to ``application/octet-stream`` so an
454 # attacker who somehow gets a signed URL for an unexpected file type can't
455 # trick the browser into sniffing executable content.
456 _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
457 "image/png",
458 "image/jpeg",
459 "image/webp",
460 "image/gif",
461 "audio/wav",
462 "audio/x-wav",
463 "audio/mpeg",
464 "audio/mp4",
465 "audio/ogg",
466 "video/mp4",
467 "video/webm",
468 "video/ogg",
469 "video/quicktime",
470 })
471
472 _REMOTE_MEDIA_SCHEMES: frozenset[str] = frozenset({"http", "https"})
473
474
475 def _guess_allowed_media_mime(name: str) -> str | None:
476 """Return the guessed MIME only when it is safe to serve inline."""
477 mime, _ = mimetypes.guess_type(name)
478 if mime in _MEDIA_ALLOWED_MIMES:
479 return mime
480 return None
481
482
483 def _media_display_name(ref: str) -> str | None:
484 """Best-effort filename label for a local path or remote URL."""
485 raw = ref.strip()
486 if not raw:
487 return None
488 parsed = urlparse(raw)
489 if parsed.scheme in (_REMOTE_MEDIA_SCHEMES | {"file"}):
490 path = unquote(parsed.path or "")
491 return Path(path).name or None
492 return Path(raw).name or None
493
494
495 def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
496 """Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
497 if not configured_secret:
498 return True
499 authorization = headers.get("Authorization") or headers.get("authorization")
500 if authorization and authorization.lower().startswith("bearer "):
501 supplied = authorization[7:].strip()
502 return hmac.compare_digest(supplied, configured_secret)
503 header_token = headers.get("X-Nanobot-Auth") or headers.get("x-nanobot-auth")
504 if not header_token:
505 return False
506 return hmac.compare_digest(header_token.strip(), configured_secret)
507
508
509 class WebSocketChannel(BaseChannel):
510 """Run a local WebSocket server; forward text/JSON messages to the message bus."""
511
512 name = "websocket"
513 display_name = "WebSocket"
514
515 def __init__(
516 self,
517 config: Any,
518 bus: MessageBus,
519 *,
520 session_manager: "SessionManager | None" = None,
521 provider: "LLMProvider | None" = None,
522 model: str | None = None,
523 static_dist_path: Path | None = None,
524 tools_config: ToolsConfig | None = None,
525 gateway_debug: bool = False,
526 memory_review_runner: Callable[..., Any] | None = None,
527 ):
528 if isinstance(config, dict):
529 config = WebSocketConfig.model_validate(config)
530 super().__init__(config, bus)
531 self.config: WebSocketConfig = config
532 # chat_id -> connections subscribed to it (fan-out target).
533 self._subs: dict[str, set[Any]] = {}
534 # connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect).
535 self._conn_chats: dict[Any, set[str]] = {}
536 # connection -> default chat_id for legacy frames that omit routing.
537 self._conn_default: dict[Any, str] = {}
538 # Single-use tokens consumed at WebSocket handshake.
539 self._issued_tokens: dict[str, float] = {}
540 # Multi-use tokens for the embedded webui's REST surface; checked but not consumed.
541 self._api_tokens: dict[str, float] = {}
542 self._stop_event: asyncio.Event | None = None
543 self._server_task: asyncio.Task[None] | None = None
544 self._session_manager = session_manager
545 self._provider = provider
546 self._model = model or (provider.get_default_model() if provider is not None else None)
547 self._tools_config = tools_config or ToolsConfig()
548 self._gateway_debug = gateway_debug
549 if memory_review_runner is None:
550 from nanobot.director.memory_coordinator import (
551 run_memory_review_from_config,
552 )
553
554 memory_review_runner = run_memory_review_from_config
555 self._memory_review_runner = memory_review_runner
556 self._static_dist_path: Path | None = (
557 static_dist_path.resolve() if static_dist_path is not None else None
558 )
559 # Process-local secret used to HMAC-sign media URLs. The signed URL is
560 # the capability — anyone who holds a valid URL can fetch that one
561 # file, nothing else. The secret regenerates on restart so links
562 # become self-expiring (callers just refresh the session list).
563 self._media_secret: bytes = secrets.token_bytes(32)
564 self._auto_generate_inflight: set[str] = set()
565 self._loop: asyncio.AbstractEventLoop | None = None
566
567 # -- Subscription bookkeeping -------------------------------------------
568
569 def _attach(self, connection: Any, chat_id: str) -> None:
570 """Idempotently subscribe *connection* to *chat_id*."""
571 self._subs.setdefault(chat_id, set()).add(connection)
572 self._conn_chats.setdefault(connection, set()).add(chat_id)
573
574 def _cleanup_connection(self, connection: Any) -> None:
575 """Remove *connection* from every subscription set; safe to call multiple times."""
576 chat_ids = self._conn_chats.pop(connection, set())
577 for cid in chat_ids:
578 subs = self._subs.get(cid)
579 if subs is None:
580 continue
581 subs.discard(connection)
582 if not subs:
583 self._subs.pop(cid, None)
584 self._conn_default.pop(connection, None)
585
586 def _webui_session_key_for_connection(self, connection: Any, chat_id: str) -> str:
587 return webui_session_key("local", chat_id)
588
589 def _session_visible_to_caller(self, session_key: str, request: WsRequest) -> bool:
590 return self._is_webui_session_key(session_key)
591
592 async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
593 """Send a control event (attached, error, ...) to a single connection."""
594 payload: dict[str, Any] = {"event": event}
595 payload.update(fields)
596 raw = json.dumps(payload, ensure_ascii=False)
597 try:
598 await connection.send(raw)
599 except ConnectionClosed:
600 self._cleanup_connection(connection)
601 except Exception as e:
602 logger.warning("websocket: failed to send {} event: {}", event, e)
603
604 @classmethod
605 def default_config(cls) -> dict[str, Any]:
606 return WebSocketConfig().model_dump(by_alias=True)
607
608 def _expected_path(self) -> str:
609 return _normalize_config_path(self.config.path)
610
611 def _build_ssl_context(self) -> ssl.SSLContext | None:
612 cert = self.config.ssl_certfile.strip()
613 key = self.config.ssl_keyfile.strip()
614 if not cert and not key:
615 return None
616 if not cert or not key:
617 raise ValueError(
618 "websocket: ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
619 )
620 ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
621 ctx.minimum_version = ssl.TLSVersion.TLSv1_2
622 ctx.load_cert_chain(certfile=cert, keyfile=key)
623 return ctx
624
625 _MAX_ISSUED_TOKENS = 10_000
626
627 def _purge_expired_issued_tokens(self) -> None:
628 now = time.monotonic()
629 for token_key, expiry in list(self._issued_tokens.items()):
630 if now > expiry:
631 self._issued_tokens.pop(token_key, None)
632
633 def _take_issued_token_if_valid(self, token_value: str | None) -> bool:
634 """Validate and consume one issued token (single use per connection attempt).
635
636 Uses single-step pop to minimize the window between lookup and removal;
637 safe under asyncio's single-threaded cooperative model.
638 """
639 if not token_value:
640 return False
641 self._purge_expired_issued_tokens()
642 expiry = self._issued_tokens.pop(token_value, None)
643 if expiry is None:
644 return False
645 if time.monotonic() > expiry:
646 return False
647 return True
648
649 def _handle_token_issue_http(self, connection: Any, request: Any) -> Any:
650 secret = self.config.token_issue_secret.strip()
651 if secret:
652 if not _issue_route_secret_matches(request.headers, secret):
653 return connection.respond(401, "Unauthorized")
654 else:
655 logger.warning(
656 "websocket: token_issue_path is set but token_issue_secret is empty; "
657 "any client can obtain connection tokens — set token_issue_secret for production."
658 )
659 self._purge_expired_issued_tokens()
660 if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
661 logger.error(
662 "websocket: too many outstanding issued tokens ({}), rejecting issuance",
663 len(self._issued_tokens),
664 )
665 return _http_json_response({"error": "too many outstanding tokens"}, status=429)
666 token_value = f"nbwt_{secrets.token_urlsafe(32)}"
667 self._issued_tokens[token_value] = time.monotonic() + float(self.config.token_ttl_s)
668
669 return _http_json_response(
670 {"token": token_value, "expires_in": self.config.token_ttl_s}
671 )
672
673 # -- HTTP dispatch ------------------------------------------------------
674
675 async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any:
676 """Route an inbound HTTP request to a handler or to the WS upgrade path."""
677 got, query = _parse_request_path(request.path)
678
679 # 1. Token issue endpoint (legacy, optional, gated by configured secret).
680 if self.config.token_issue_path:
681 issue_expected = _normalize_config_path(self.config.token_issue_path)
682 if got == issue_expected:
683 return self._handle_token_issue_http(connection, request)
684
685 # 2. WebUI bootstrap: mints short-lived local transport tokens.
686 if got == "/webui/bootstrap":
687 return self._handle_webui_bootstrap(connection, request)
688
689 # 3. REST surface for the embedded UI.
690 if got == "/api/sessions":
691 return self._handle_sessions_list(request)
692
693 m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
694 if m:
695 return self._handle_session_messages(request, m.group(1))
696
697 # NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a
698 # true ``DELETE`` verb. The action is folded into the path instead.
699 m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
700 if m:
701 return self._handle_session_delete(request, m.group(1))
702
703 m = re.match(r"^/api/sessions/([^/]+)/generation-settings/save$", got)
704 if m:
705 return self._handle_generation_settings_save(request, m.group(1))
706
707 m = re.match(r"^/api/sessions/([^/]+)/generation-settings$", got)
708 if m:
709 return self._handle_generation_settings_get(request, m.group(1))
710
711 m = re.match(r"^/api/workplace/([^/]+)$", got)
712 if m:
713 return self._handle_workplace_status(request, m.group(1))
714
715 m = re.match(r"^/api/workplace/([^/]+)/story/save$", got)
716 if m:
717 return self._handle_workplace_story_save(request, m.group(1))
718
719 m = re.match(r"^/api/workplace/([^/]+)/story-profile/save$", got)
720 if m:
721 return self._handle_workplace_story_profile_save(request, m.group(1))
722
723 m = re.match(r"^/api/workplace/([^/]+)/reference-image/save$", got)
724 if m:
725 return self._handle_workplace_reference_image_save(request, m.group(1))
726
727 m = re.match(r"^/api/workplace/([^/]+)/reference-image/delete$", got)
728 if m:
729 return self._handle_workplace_reference_image_delete(request, m.group(1))
730
731 # Alias: GET /reference-image with X-Nanobot-Body is save (websockets is GET-only).
732 m = re.match(r"^/api/workplace/([^/]+)/reference-image$", got)
733 if m:
734 return self._handle_workplace_reference_image_save(request, m.group(1))
735
736 m = re.match(r"^/api/workplace/([^/]+)/shots/accept-all$", got)
737 if m:
738 return self._handle_workplace_shot_accept_all(request, m.group(1))
739
740 m = re.match(
741 r"^/api/workplace/([^/]+)/shots/(\d+)/memory-review/(approve|reselect|manual-select|select-mode)$", got
742 )
743 if m:
744 return self._handle_memory_review_action(
745 request, m.group(1), int(m.group(2)), m.group(3)
746 )
747 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/accept$", got)
748 if m:
749 return self._handle_workplace_shot_accept(request, m.group(1), int(m.group(2)))
750
751 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/revise$", got)
752 if m:
753 return self._handle_workplace_shot_revise(request, m.group(1), int(m.group(2)))
754
755 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/merge-up$", got)
756 if m:
757 return self._handle_workplace_shot_merge_up(request, m.group(1), int(m.group(2)))
758
759 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/remove-shot$", got)
760 if m:
761 return self._handle_workplace_shot_remove_shot(request, m.group(1), int(m.group(2)))
762
763 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/split-shot$", got)
764 if m:
765 return self._handle_workplace_shot_split_shot(request, m.group(1), int(m.group(2)))
766
767 m = re.match(r"^/api/workplace/([^/]+)/workflow/confirm-story$", got)
768 if m:
769 return self._handle_workplace_workflow_confirm_story(request, m.group(1))
770
771 m = re.match(r"^/api/workplace/([^/]+)/workflow/start-generation$", got)
772 if m:
773 return self._handle_workplace_workflow_start_generation(request, m.group(1))
774
775 m = re.match(r"^/api/workplace/([^/]+)/workflow/abort-generation$", got)
776 if m:
777 return self._handle_workplace_workflow_abort_generation(request, m.group(1))
778
779 m = re.match(r"^/api/workplace/([^/]+)/workflow/generate-all$", got)
780 if m:
781 return await self._handle_workplace_workflow_generate_all(request, m.group(1))
782
783 m = re.match(r"^/api/workplace/([^/]+)/workflow/auto-generate$", got)
784 if m:
785 return self._handle_workplace_workflow_auto_generate(request, m.group(1))
786
787 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/generate$", got)
788 if m:
789 return await self._handle_workplace_shot_generate(
790 request, m.group(1), int(m.group(2))
791 )
792
793 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/continuous-generate$", got)
794 if m:
795 return self._handle_workplace_shot_continuous_generate(request, m.group(1), int(m.group(2)))
796
797 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/continuous-mode$", got)
798 if m:
799 return self._handle_workplace_shot_continuous_mode(request, m.group(1), int(m.group(2)))
800
801 m = re.match(r"^/api/workplace/([^/]+)/shots/(\d+)/duration$", got)
802 if m:
803 return self._handle_workplace_shot_duration(request, m.group(1), int(m.group(2)))
804
805 m = re.match(r"^/api/workplace/([^/]+)/workflow/start-merge$", got)
806 if m:
807 return self._handle_workplace_workflow_start_merge(request, m.group(1))
808
809 m = re.match(r"^/api/workplace/([^/]+)/workflow/regenerate$", got)
810 if m:
811 return self._handle_workplace_workflow_regenerate(request, m.group(1))
812
813 m = re.match(r"^/api/workplace/([^/]+)/echo/like$", got)
814 if m:
815 return self._handle_echo_like(request, m.group(1))
816
817 m = re.match(r"^/api/workplace/([^/]+)/echo/download-prompt$", got)
818 if m:
819 return self._handle_echo_download_prompt(request, m.group(1))
820
821 m = re.match(r"^/api/workplace/([^/]+)/download/final$", got)
822 if m:
823 return self._handle_workplace_download_final(request, m.group(1))
824
825 # Signed media fetch: ``<sig>`` is an HMAC over ``<payload>``; the
826 # payload decodes to a path inside :func:`get_media_dir`. See
827 # :meth:`_sign_media_path` for the inverse direction used to build
828 # these URLs when replaying a session.
829 m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
830 if m:
831 return self._handle_media_fetch(m.group(1), m.group(2))
832
833 asset_prefix = (
834 "/"
835 + self._tools_config.file_storage.local.route_prefix.strip().strip("/")
836 )
837 if got.startswith(f"{asset_prefix}/"):
838 return self._handle_local_asset_fetch(got)
839
840 # PromptStack API
841 if got == "/api/promptstack/sessions":
842 if not self._gateway_debug:
843 return _http_error(404, "Not found")
844 return self._handle_promptstack_sessions(request)
845
846 m = re.match(r"^/api/promptstack/traces/([A-Za-z0-9_.-]+)$", got)
847 if m:
848 if not self._gateway_debug:
849 return _http_error(404, "Not found")
850 return self._handle_promptstack_trace(request, m.group(1))
851
852 # EventStack API
853 if got == "/api/eventstack/sessions":
854 if not self._gateway_debug:
855 return _http_error(404, "Not found")
856 return self._handle_eventstack_sessions(request)
857
858 m = re.match(r"^/api/eventstack/traces/([A-Za-z0-9_.-]+)$", got)
859 if m:
860 if not self._gateway_debug:
861 return _http_error(404, "Not found")
862 return self._handle_eventstack_trace(request, m.group(1))
863
864 # PE (Prompt Engineering) set list + active
865 if got == "/api/pe-sets":
866 return self._handle_pe_list(request)
867
868 # 4. WebSocket upgrade (the channel's primary purpose). Only run the
869 # handshake gate on requests that actually ask to upgrade; otherwise
870 # a bare ``GET /`` from the browser would be rejected as an
871 # unauthorized WS handshake instead of serving the SPA's index.html.
872 expected_ws = self._expected_path()
873 if got == expected_ws and _is_websocket_upgrade(request):
874 client_id = _query_first(query, "client_id") or ""
875 if len(client_id) > 128:
876 client_id = client_id[:128]
877 if not self.is_allowed(client_id):
878 return connection.respond(403, "Forbidden")
879 return self._authorize_websocket_handshake(connection, query)
880
881 # 5. Static SPA serving (only if a build directory was wired in).
882 if self._static_dist_path is not None:
883 response = self._serve_static(got)
884 if response is not None:
885 return response
886
887 return connection.respond(404, "Not Found")
888
889 # -- HTTP route handlers ------------------------------------------------
890
891 def _check_api_token(self, request: WsRequest) -> bool:
892 """Validate the short-lived local WebUI transport token."""
893 self._purge_expired_api_tokens()
894 token = _bearer_token(request.headers) or _query_first(
895 _parse_query(request.path), "token"
896 )
897 if not token:
898 return False
899 expiry = self._api_tokens.get(token)
900 if expiry is None or time.monotonic() > expiry:
901 self._api_tokens.pop(token, None)
902 return False
903 return True
904
905 def _purge_expired_api_tokens(self) -> None:
906 now = time.monotonic()
907 for token_key, expiry in list(self._api_tokens.items()):
908 if now > expiry:
909 self._api_tokens.pop(token_key, None)
910
911 def _handle_webui_bootstrap(self, connection: Any, request: WsRequest) -> Response:
912 from nanobot.prompts import PEManager
913
914 active_pe = PEManager.instance().active
915 if not _is_localhost(connection):
916 return _http_error(403, "webui bootstrap is localhost-only")
917
918 self._purge_expired_issued_tokens()
919 self._purge_expired_api_tokens()
920 if (
921 len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS
922 or len(self._api_tokens) >= self._MAX_ISSUED_TOKENS
923 ):
924 return _http_json_response({"error": "too many outstanding tokens"}, status=429)
925
926 token_value = f"nbwt_{secrets.token_urlsafe(32)}"
927 expiry = time.monotonic() + float(self.config.token_ttl_s)
928 self._issued_tokens[token_value] = expiry
929 self._api_tokens[token_value] = expiry
930 return _http_json_response(
931 {
932 "ws_path": self._expected_path(),
933 "token": token_value,
934 "expires_in": int(self.config.token_ttl_s),
935 "model_name": _read_webui_model_name(),
936 "user_id": "local",
937 "active_pe": active_pe,
938 }
939 )
940
941 def _handle_sessions_list(self, request: WsRequest) -> Response:
942 if not self._check_api_token(request):
943 return _http_error(401, "Unauthorized")
944 if self._session_manager is None:
945 return _http_error(503, "session manager unavailable")
946 sessions = self._session_manager.list_sessions()
947 # The webui is only meaningful for websocket-channel chats — CLI /
948 # Slack / Lark / Discord sessions can't be resumed from the browser,
949 # so leaking them into the sidebar is just noise. Filter to the
950 # ``websocket:`` prefix and strip absolute paths on the way out.
951 cleaned = [
952 {
953 **{k: v for k, v in s.items() if k != "path"},
954 **self._session_echo_tracking_fields(s.get("key")),
955 }
956 for s in sessions
957 if isinstance(s.get("key"), str)
958 and self._is_webui_session_key(s["key"])
959 and self._session_visible_to_caller(s["key"], request)
960 ]
961 return _http_json_response({"sessions": cleaned})
962
963 @staticmethod
964 def _is_webui_session_key(key: str) -> bool:
965 """Return True when *key* belongs to the webui's websocket-only surface."""
966 return key.startswith("websocket:")
967
968 @staticmethod
969 def _legacy_webui_session_key_error(raw_key: str) -> Response | None:
970 decoded = _decode_api_key(raw_key)
971 if decoded is not None and is_legacy_two_part_webui_session_key(decoded):
972 return _http_error(400, "invalid session key: two-part format is no longer supported")
973 return None
974
975 def _resolve_webui_api_session_key(self, raw_key: str, request: WsRequest) -> str | None:
976 """Decode and authorize a three-part WebUI session key."""
977 decoded = _decode_api_key(raw_key)
978 if decoded is None or not self._is_webui_session_key(decoded):
979 return None
980 if is_legacy_two_part_webui_session_key(decoded):
981 return None
982 if self._session_visible_to_caller(decoded, request):
983 return decoded
984 return None
985
986 def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
987 if not self._check_api_token(request):
988 return _http_error(401, "Unauthorized")
989 if self._session_manager is None:
990 return _http_error(503, "session manager unavailable")
991 legacy_err = self._legacy_webui_session_key_error(key)
992 if legacy_err is not None:
993 return legacy_err
994 decoded_key = self._resolve_webui_api_session_key(key, request)
995 if decoded_key is None:
996 if _decode_api_key(key) is None:
997 return _http_error(400, "invalid session key")
998 return _http_error(404, "session not found")
999 data = self._session_manager.read_session_file(decoded_key)
1000 if data is None:
1001 # Brand-new chats exist only in the browser until the first turn is
1002 # persisted — return an empty history instead of a misleading 404.
1003 return _http_json_response(
1004 {
1005 "key": decoded_key,
1006 "created_at": None,
1007 "updated_at": None,
1008 "messages": [],
1009 }
1010 )
1011 # Decorate persisted media refs with browser-usable URLs so the
1012 # client can render previews. Raw local paths are stripped on the
1013 # way out — they leak server filesystem layout and the client never
1014 # needs them once it has a signed fetch URL.
1015 self._augment_media_urls(data)
1016 return _http_json_response(data)
1017
1018 def _augment_media_urls(self, payload: dict[str, Any]) -> None:
1019 """Mutate *payload* in place: each message's ``media`` ref list is
1020 replaced by a parallel ``media_urls`` list of browser-usable URLs.
1021
1022 Messages without media or with non-string path entries are left
1023 untouched. Missing / unsupported local files are silently skipped;
1024 the client falls back to text-only history in that case.
1025 """
1026 messages = payload.get("messages")
1027 if not isinstance(messages, list):
1028 return
1029 for msg in messages:
1030 if not isinstance(msg, dict):
1031 continue
1032 media = msg.get("media")
1033 if not isinstance(media, list) or not media:
1034 continue
1035 urls: list[dict[str, str]] = []
1036 for entry in media:
1037 if not isinstance(entry, str) or not entry:
1038 continue
1039 public_ref = self._public_media_entry(entry)
1040 if public_ref is None:
1041 continue
1042 urls.append(public_ref)
1043 if urls:
1044 msg["media_urls"] = urls
1045 # Always drop the raw paths from the wire payload.
1046 msg.pop("media", None)
1047
1048 def _sign_media_path(self, abs_path: Path) -> str | None:
1049 """Return a ``/api/media/<sig>/<payload>`` URL for *abs_path*, or
1050 ``None`` when the path does not resolve inside the media root.
1051
1052 The URL is self-authenticating: the signature binds the payload to
1053 this process's ``_media_secret``, so only paths we chose to sign can
1054 be fetched. The returned path is relative to the server origin; the
1055 client joins it against the existing webui base.
1056 """
1057 try:
1058 media_root = get_media_dir().resolve()
1059 rel = abs_path.resolve().relative_to(media_root)
1060 except (OSError, ValueError):
1061 return None
1062 payload = _b64url_encode(rel.as_posix().encode("utf-8"))
1063 mac = hmac.new(
1064 self._media_secret, payload.encode("ascii"), hashlib.sha256
1065 ).digest()[:16]
1066 return f"/api/media/{_b64url_encode(mac)}/{payload}"
1067
1068 def _sign_local_media_path(self, abs_path: Path) -> str | None:
1069 """Return a signed fetch URL for an arbitrary local media file.
1070
1071 Unlike :meth:`_sign_media_path`, this path may live outside the
1072 websocket media dir (for example director-generated video shots in
1073 ``/tmp``). Only files with an allow-listed image/video MIME are
1074 signed so the route does not become a generic file browser.
1075 """
1076 try:
1077 candidate = abs_path.expanduser().resolve()
1078 except OSError:
1079 return None
1080 if not candidate.is_file():
1081 return None
1082 if _guess_allowed_media_mime(candidate.name) is None:
1083 return None
1084 payload = _b64url_encode(candidate.as_posix().encode("utf-8"))
1085 mac = hmac.new(
1086 self._media_secret, payload.encode("ascii"), hashlib.sha256
1087 ).digest()[:16]
1088 return f"/api/media/{_b64url_encode(mac)}/{payload}"
1089
1090 def _public_media_entry(self, entry: str) -> dict[str, str] | None:
1091 """Return a browser-usable media ref for a local path or remote URL."""
1092 raw = entry.strip()
1093 if not raw:
1094 return None
1095 parsed = urlparse(raw)
1096 name = _media_display_name(raw)
1097 if parsed.scheme in _REMOTE_MEDIA_SCHEMES:
1098 result = {"url": raw}
1099 if name:
1100 result["name"] = name
1101 return result
1102 if raw.startswith("/api/media/"):
1103 result = {"url": raw}
1104 if name:
1105 result["name"] = name
1106 return result
1107 local_asset = None
1108 if self._session_manager is not None:
1109 local_asset = resolve_local_asset_path(
1110 raw,
1111 workspace=self._session_manager.workspace,
1112 config=self._tools_config.file_storage.local,
1113 )
1114 if parsed.scheme == "file":
1115 local = Path(unquote(parsed.path or ""))
1116 elif local_asset is not None:
1117 local = local_asset
1118 else:
1119 local = Path(raw).expanduser()
1120 signed = self._sign_media_path(local)
1121 if signed is None:
1122 signed = self._sign_local_media_path(local)
1123 if signed is None:
1124 return None
1125 result = {"url": signed}
1126 if name:
1127 result["name"] = name
1128 return result
1129
1130 def _handle_media_fetch(self, sig: str, payload: str) -> Response:
1131 """Serve a single media file previously signed via
1132 :meth:`_sign_media_path`. Validates the signature, decodes the
1133 payload to a relative path, and streams the file bytes with a
1134 long-lived immutable cache header (the URL already encodes the
1135 file identity, so caches can be aggressive)."""
1136 try:
1137 provided_mac = _b64url_decode(sig)
1138 except (ValueError, binascii.Error):
1139 return _http_error(401, "invalid signature")
1140 expected_mac = hmac.new(
1141 self._media_secret, payload.encode("ascii"), hashlib.sha256
1142 ).digest()[:16]
1143 if not hmac.compare_digest(expected_mac, provided_mac):
1144 return _http_error(401, "invalid signature")
1145 try:
1146 rel_bytes = _b64url_decode(payload)
1147 rel_str = rel_bytes.decode("utf-8")
1148 except (ValueError, binascii.Error, UnicodeDecodeError):
1149 return _http_error(400, "invalid payload")
1150 try:
1151 requested = Path(rel_str).expanduser()
1152 if requested.is_absolute():
1153 candidate = requested.resolve()
1154 if _guess_allowed_media_mime(candidate.name) is None:
1155 return _http_error(404, "not found")
1156 else:
1157 # Legacy payload shape: relative to ``media_dir``.
1158 media_root = get_media_dir().resolve()
1159 candidate = (media_root / rel_str).resolve()
1160 candidate.relative_to(media_root)
1161 except (OSError, ValueError):
1162 return _http_error(404, "not found")
1163 if not candidate.is_file():
1164 return _http_error(404, "not found")
1165 try:
1166 body = candidate.read_bytes()
1167 except OSError:
1168 return _http_error(500, "read error")
1169 mime, _ = mimetypes.guess_type(candidate.name)
1170 if mime not in _MEDIA_ALLOWED_MIMES:
1171 mime = "application/octet-stream"
1172 return _http_response(
1173 body,
1174 content_type=mime,
1175 extra_headers=[
1176 ("Cache-Control", "private, max-age=31536000, immutable"),
1177 # Paired with the MIME whitelist above: prevents browsers from
1178 # MIME-sniffing an octet-stream fallback into executable HTML.
1179 ("X-Content-Type-Options", "nosniff"),
1180 ],
1181 )
1182
1183 def _handle_local_asset_fetch(self, request_path: str) -> Response:
1184 """Serve a content-addressed Memory asset from the local workspace."""
1185 if self._session_manager is None:
1186 return _http_error(503, "session manager unavailable")
1187 path = resolve_local_asset_path(
1188 request_path,
1189 workspace=self._session_manager.workspace,
1190 config=self._tools_config.file_storage.local,
1191 )
1192 if path is None:
1193 return _http_error(404, "not found")
1194 mime = _guess_allowed_media_mime(path.name)
1195 if mime is None:
1196 return _http_error(415, "unsupported media type")
1197 try:
1198 body = path.read_bytes()
1199 except OSError:
1200 return _http_error(404, "not found")
1201 return _http_response(
1202 body,
1203 content_type=mime,
1204 extra_headers=[("Cache-Control", "public, max-age=31536000, immutable")],
1205 )
1206
1207 def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
1208 if not self._check_api_token(request):
1209 return _http_error(401, "Unauthorized")
1210 if self._session_manager is None:
1211 return _http_error(503, "session manager unavailable")
1212 legacy_err = self._legacy_webui_session_key_error(key)
1213 if legacy_err is not None:
1214 return legacy_err
1215 decoded_key = self._resolve_webui_api_session_key(key, request)
1216 if decoded_key is None:
1217 if _decode_api_key(key) is None:
1218 return _http_error(400, "invalid session key")
1219 return _http_error(404, "session not found")
1220 deleted = self._session_manager.delete_session(decoded_key)
1221 return _http_json_response({"deleted": bool(deleted)})
1222
1223 def _director_root(self) -> Path | None:
1224 if self._session_manager is None:
1225 return None
1226 return self._session_manager.workspace / "director"
1227
1228 def _workplace_paths(self, work_id: str) -> dict[str, Path] | None:
1229 root = self._director_root()
1230 if root is None:
1231 return None
1232 work_dir = root / "works" / work_id
1233 return {
1234 "work_dir": work_dir,
1235 "story": work_dir / "story.md",
1236 "story_profile": work_dir / "story_profile.json",
1237 "state": work_dir / "state.json",
1238 "shots": work_dir / "shots",
1239 "memory_bank": work_dir / "memory" / "memory_bank.json",
1240 "previous_shot_memory": work_dir / "memory" / "previous_shot.json",
1241 "manual_memory_workspace": work_dir / "memory" / "manual" / "workspace.json",
1242 "memory_asset_profiles": work_dir / "memory" / "asset_profiles.json",
1243 }
1244
1245 @staticmethod
1246 def _read_json_file(path: Path, default: Any) -> Any:
1247 if not path.exists():
1248 return default
1249 try:
1250 return json.loads(path.read_text(encoding="utf-8"))
1251 except (OSError, json.JSONDecodeError):
1252 return default
1253
1254 @staticmethod
1255 def _write_json_file(path: Path, payload: dict[str, Any]) -> None:
1256 write_json_atomic(path, payload)
1257
1258 @staticmethod
1259 def _work_id_from_session_map_entry(entry: Any) -> str | None:
1260 if isinstance(entry, dict):
1261 work_id = entry.get("active")
1262 elif isinstance(entry, str):
1263 work_id = entry
1264 else:
1265 return None
1266 return work_id if isinstance(work_id, str) and work_id.strip() else None
1267
1268 @staticmethod
1269 def _session_map_lookup_keys(session_key: str) -> list[str]:
1270 """Candidate session_map keys, including legacy two-part aliases."""
1271 keys: list[str] = []
1272 seen: set[str] = set()
1273
1274 def add(key: str) -> None:
1275 if key and key not in seen:
1276 seen.add(key)
1277 keys.append(key)
1278
1279 add(session_key)
1280 chat_id = webui_wire_chat_id(session_key)
1281 if chat_id:
1282 add(f"websocket:{chat_id}")
1283 return keys
1284
1285 def _resolve_generation_api_session(self, request: WsRequest, key: str) -> str | Response:
1286 if not self._check_api_token(request):
1287 return _http_error(401, "Unauthorized")
1288 if self._session_manager is None:
1289 return _http_error(503, "session manager unavailable")
1290 legacy_err = self._legacy_webui_session_key_error(key)
1291 if legacy_err is not None:
1292 return legacy_err
1293 decoded_key = self._resolve_webui_api_session_key(key, request)
1294 if decoded_key is None:
1295 if _decode_api_key(key) is None:
1296 return _http_error(400, "invalid session key")
1297 return _http_error(404, "session not found")
1298 return decoded_key
1299
1300 def _echo_tracking_payload(self, state: dict[str, Any] | None) -> dict[str, Any]:
1301 payload = state if isinstance(state, dict) else {}
1302 return {
1303 "echo_request_id": payload.get("echo_request_id"),
1304 "like_status": int(payload.get("like_status") or 0),
1305 "prompt_downloaded": bool(payload.get("prompt_downloaded")),
1306 "video_downloaded": bool(payload.get("video_downloaded")),
1307 }
1308
1309 def _session_echo_tracking_fields(self, session_key: Any) -> dict[str, Any]:
1310 if not isinstance(session_key, str) or self._session_manager is None:
1311 return self._echo_tracking_payload(None)
1312 work_id = self._resolve_work_id_for_session(session_key)
1313 if work_id:
1314 paths = self._workplace_paths(work_id)
1315 if paths is not None:
1316 state = self._read_json_file(paths["state"], {})
1317 if isinstance(state, dict):
1318 return self._echo_tracking_payload(state)
1319 return self._echo_tracking_payload(None)
1320
1321 def _resolve_echo_tracking_context(self, session_key: str) -> dict[str, Any] | None:
1322 work_id = self._resolve_work_id_for_session(session_key)
1323 if work_id:
1324 paths = self._workplace_paths(work_id)
1325 if paths is not None:
1326 state = self._read_json_file(paths["state"], {})
1327 if isinstance(state, dict):
1328 return {
1329 "kind": "director",
1330 "state": state,
1331 "work_id": work_id,
1332 "session_key": session_key,
1333 }
1334 return None
1335
1336 def _save_echo_tracking_context(self, ctx: dict[str, Any]) -> None:
1337 state = ctx["state"]
1338 if ctx.get("work_id"):
1339 self._save_workplace_state(str(ctx["work_id"]), state)
1340
1341 def _handle_echo_like(self, request: WsRequest, key: str) -> Response:
1342 resolved = self._resolve_generation_api_session(request, key)
1343 if not isinstance(resolved, str):
1344 return resolved
1345 decoded_key = resolved
1346 tracking_ctx = self._resolve_echo_tracking_context(decoded_key)
1347 if tracking_ctx is None:
1348 return _http_error(409, "echo tracking state is not ready")
1349 state = tracking_ctx["state"]
1350 query = _parse_query(request.path)
1351 raw_status = _query_first(query, "like_status")
1352 if raw_status is None:
1353 body = self._parse_json_body_payload(request)
1354 if isinstance(body, dict):
1355 raw_status = body.get("like_status")
1356 if raw_status is None:
1357 raw_status = body.get("likeStatus")
1358 try:
1359 action = int(raw_status)
1360 except (TypeError, ValueError):
1361 return _http_error(400, "invalid like_status")
1362 if action not in {1, 2}:
1363 return _http_error(400, "like_status must be 1 or 2")
1364 current = int(state.get("like_status") or 0)
1365 next_status = 0 if current == action else action
1366
1367 state["like_status"] = next_status
1368 state["updated_at"] = datetime.now(timezone.utc).isoformat()
1369 self._save_echo_tracking_context(tracking_ctx)
1370 workplace = self._build_workplace_payload(decoded_key)
1371 return _http_json_response(
1372 {
1373 "ok": True,
1374 "session_key": decoded_key,
1375 **self._echo_tracking_payload(state),
1376 "workplace": workplace,
1377 }
1378 )
1379
1380 def _handle_echo_download_prompt(self, request: WsRequest, key: str) -> Response:
1381 resolved = self._resolve_generation_api_session(request, key)
1382 if not isinstance(resolved, str):
1383 return resolved
1384 decoded_key = resolved
1385 tracking_ctx = self._resolve_echo_tracking_context(decoded_key)
1386 if tracking_ctx is None:
1387 return _http_error(409, "echo tracking state is not ready")
1388 state = tracking_ctx["state"]
1389 state["prompt_downloaded"] = True
1390 state["updated_at"] = datetime.now(timezone.utc).isoformat()
1391 self._save_echo_tracking_context(tracking_ctx)
1392 workplace = self._build_workplace_payload(decoded_key)
1393 return _http_json_response(
1394 {
1395 "ok": True,
1396 "session_key": decoded_key,
1397 **self._echo_tracking_payload(state),
1398 "workplace": workplace,
1399 }
1400 )
1401
1402 def _persist_session_source(self, session_key: str, source: str | None) -> None:
1403 if self._session_manager is None:
1404 return
1405 normalized = normalize_source(source)
1406 if not normalized:
1407 return
1408 session = self._session_manager.get_or_create(session_key)
1409 if apply_source(session.metadata, normalized):
1410 self._session_manager.save(session)
1411 logger.info(
1412 "Persisted session source={} session_key={}",
1413 normalized,
1414 session_key,
1415 )
1416
1417 def _persist_session_pe(self, session_key: str, name: str) -> None:
1418 """Persist the chosen PE set to session metadata so it survives restart/reconnect."""
1419 if not session_key or self._session_manager is None:
1420 return
1421 session = self._session_manager.get_or_create(session_key)
1422 if session.metadata.get("pe_set") == name:
1423 return
1424 session.metadata["pe_set"] = name
1425 self._session_manager.save(session)
1426 logger.info("Persisted pe_set={} session_key={}", name, session_key)
1427
1428 def _hydrate_session_pe(self, session_key: str) -> None:
1429 """Restore a persisted PE selection into the PEManager if not already bound."""
1430 if not session_key or self._session_manager is None:
1431 return
1432 from nanobot.prompts import PEManager
1433
1434 manager = PEManager.instance()
1435 if manager.active_for_session(session_key) != manager.active:
1436 return # already has an in-memory override
1437 session = self._session_manager.get_or_create(session_key)
1438 stored = session.metadata.get("pe_set")
1439 if isinstance(stored, str) and stored:
1440 manager.set_active_for_session(session_key, stored)
1441
1442 def _handle_pe_list(self, request: WsRequest) -> Response:
1443 if not self._check_api_token(request):
1444 return _http_error(401, "Unauthorized")
1445 from nanobot.prompts import PEManager
1446
1447 manager = PEManager.instance()
1448 return _http_json_response(
1449 {
1450 "ok": True,
1451 "sets": manager.list_sets(),
1452 "active": manager.active,
1453 "enabled": manager.enabled,
1454 }
1455 )
1456
1457 def _handle_generation_settings_get(self, request: WsRequest, key: str) -> Response:
1458 if not self._check_api_token(request):
1459 return _http_error(401, "Unauthorized")
1460 if self._session_manager is None:
1461 return _http_error(503, "session manager unavailable")
1462 legacy_err = self._legacy_webui_session_key_error(key)
1463 if legacy_err is not None:
1464 return legacy_err
1465 decoded_key = self._resolve_webui_api_session_key(key, request)
1466 if decoded_key is None:
1467 if _decode_api_key(key) is None:
1468 return _http_error(400, "invalid session key")
1469 return _http_error(404, "session not found")
1470
1471 from nanobot.session.generation_settings import (
1472 get_generation_settings,
1473 get_llm_sampling_for_api,
1474 )
1475
1476 session = self._session_manager.get_or_create(decoded_key)
1477 settings = get_generation_settings(session.metadata)
1478 settings.update(get_llm_sampling_for_api(session.metadata))
1479 return _http_json_response({"ok": True, "session_key": decoded_key, **settings})
1480
1481 def _parse_generation_settings_body(self, request: WsRequest) -> dict[str, Any]:
1482 query = _parse_query(request.path)
1483 body = self._parse_json_body_payload(request)
1484 payload: dict[str, Any] = {}
1485 if isinstance(body, dict):
1486 payload.update(body)
1487 for q_key, p_key in (
1488 ("duration_sec", "duration_sec"),
1489 ("n_shots", "n_shots"),
1490 ("width", "width"),
1491 ("height", "height"),
1492 ("language", "language"),
1493 ("temperature", "temperature"),
1494 ("top_p", "top_p"),
1495 ("top_k", "top_k"),
1496 ):
1497 raw = _query_first(query, q_key)
1498 if raw is not None:
1499 payload[p_key] = raw
1500 if payload.get("duration_sec") is None and "durationSec" in payload:
1501 payload["duration_sec"] = payload.get("durationSec")
1502 if payload.get("n_shots") is None and payload.get("nShot") is not None:
1503 payload["n_shots"] = payload.get("nShot")
1504 if payload.get("top_p") is None and payload.get("topP") is not None:
1505 payload["top_p"] = payload.get("topP")
1506 if payload.get("top_k") is None and payload.get("topK") is not None:
1507 payload["top_k"] = payload.get("topK")
1508 return payload
1509
1510 def _handle_generation_settings_save(self, request: WsRequest, key: str) -> Response:
1511 if not self._check_api_token(request):
1512 return _http_error(401, "Unauthorized")
1513 if self._session_manager is None:
1514 return _http_error(503, "session manager unavailable")
1515 legacy_err = self._legacy_webui_session_key_error(key)
1516 if legacy_err is not None:
1517 return legacy_err
1518 decoded_key = self._resolve_webui_api_session_key(key, request)
1519 if decoded_key is None:
1520 if _decode_api_key(key) is None:
1521 return _http_error(400, "invalid session key")
1522 return _http_error(404, "session not found")
1523
1524 from nanobot.session.generation_settings import (
1525 apply_generation_settings,
1526 apply_llm_sampling_settings,
1527 get_generation_settings,
1528 get_llm_sampling_for_api,
1529 normalize_duration_sec,
1530 normalize_language,
1531 normalize_n_shots,
1532 )
1533
1534 payload = self._parse_generation_settings_body(request)
1535 duration_raw = payload.get("duration_sec")
1536 nshot_raw = payload.get("n_shots")
1537 width_raw = payload.get("width")
1538 height_raw = payload.get("height")
1539 language_raw = payload.get("language")
1540 has_duration = duration_raw is not None and duration_raw != ""
1541 has_nshots = nshot_raw is not None and nshot_raw != ""
1542 has_size = width_raw not in (None, "") or height_raw not in (None, "")
1543 has_language = language_raw not in (None, "")
1544 has_llm = any(
1545 key in payload
1546 for key in ("temperature", "top_p", "top_k", "topP", "topK")
1547 )
1548 if (
1549 not has_duration
1550 and not has_nshots
1551 and not has_size
1552 and not has_language
1553 and not has_llm
1554 ):
1555 return _http_error(400, "no settings to save")
1556
1557 duration_sec = normalize_duration_sec(duration_raw) if has_duration else None
1558 n_shots = normalize_n_shots(nshot_raw) if has_nshots else None
1559 language = normalize_language(language_raw) if has_language else None
1560 if has_duration and duration_sec is None:
1561 return _http_error(400, f"invalid duration_sec: {duration_raw}")
1562 if has_nshots and n_shots is None:
1563 return _http_error(400, f"invalid n_shots: {nshot_raw}")
1564 if has_language and language is None:
1565 return _http_error(400, f"invalid language: {language_raw}")
1566
1567 session = self._session_manager.get_or_create(decoded_key)
1568 try:
1569 if duration_sec is not None or n_shots is not None or has_size or language is not None:
1570 apply_generation_settings(
1571 session.metadata,
1572 n_shots=n_shots,
1573 duration_sec=duration_sec,
1574 width=width_raw if width_raw not in (None, "") else None,
1575 height=height_raw if height_raw not in (None, "") else None,
1576 language=language,
1577 )
1578 llm_kwargs: dict[str, Any] = {}
1579 if "temperature" in payload:
1580 llm_kwargs["temperature"] = payload.get("temperature")
1581 if "top_p" in payload or "topP" in payload:
1582 llm_kwargs["top_p"] = payload.get("top_p", payload.get("topP"))
1583 if "top_k" in payload or "topK" in payload:
1584 llm_kwargs["top_k"] = payload.get("top_k", payload.get("topK"))
1585 if llm_kwargs:
1586 apply_llm_sampling_settings(session.metadata, **llm_kwargs)
1587 except ValueError as exc:
1588 return _http_error(400, str(exc))
1589 self._session_manager.save(session)
1590 if language is not None:
1591 self._sync_story_profile_language(decoded_key, language)
1592 settings = get_generation_settings(session.metadata)
1593 settings.update(get_llm_sampling_for_api(session.metadata))
1594 return _http_json_response({"ok": True, "session_key": decoded_key, **settings})
1595
1596
1597
1598
1599
1600
1601 _I2V_IMAGE_DOWNLOAD_TIMEOUT_S = 15.0
1602 _I2V_IMAGE_MAX_BYTES = 20 * 1024 * 1024 # 20 MiB
1603
1604 @staticmethod
1605 def _shot_recaption_prompt_path() -> Path:
1606 return (
1607 Path(__file__).resolve().parents[2]
1608 / "pe"
1609 / "v7_cinematic_full"
1610 / "references"
1611 / "shot-prompt-writer.md"
1612 )
1613
1614 @staticmethod
1615 def _i2v_prompt_skill_path() -> Path:
1616 return (
1617 Path(__file__).resolve().parents[2]
1618 / "pe"
1619 / "v7_cinematic_full"
1620 / "skills"
1621 / "i2v-tail-frame-prompt-rewriter"
1622 / "SKILL.md"
1623 )
1624
1625 @staticmethod
1626 async def _http_download_image_as_base64_uri(image_url: str) -> str:
1627 """Download an image and return a data URI for the configured VLM."""
1628 loop = asyncio.get_running_loop()
1629
1630 def _download() -> tuple[str, bytes]:
1631 request = urllib.request.Request(image_url, method="GET")
1632 with urllib.request.urlopen(
1633 request,
1634 timeout=WebSocketChannel._I2V_IMAGE_DOWNLOAD_TIMEOUT_S,
1635 ) as response:
1636 return response.headers.get("Content-Type", ""), response.read()
1637
1638 try:
1639 content_type, data = await loop.run_in_executor(None, _download)
1640 except urllib.error.HTTPError as exc:
1641 raise RuntimeError(
1642 f"Failed to download reference image (HTTP {exc.code}): {image_url}"
1643 ) from exc
1644 except (urllib.error.URLError, TimeoutError, OSError) as exc:
1645 raise RuntimeError(f"Failed to download reference image: {exc}") from exc
1646
1647 if len(data) > WebSocketChannel._I2V_IMAGE_MAX_BYTES:
1648 raise RuntimeError(
1649 f"Reference image too large ({len(data)} bytes, max "
1650 f"{WebSocketChannel._I2V_IMAGE_MAX_BYTES})"
1651 )
1652
1653 mime_type = content_type.split(";")[0].strip()
1654 if not mime_type or mime_type == "application/octet-stream":
1655 extension = os.path.splitext(urlparse(image_url).path)[1].lower()
1656 mime_type = {
1657 ".jpg": "image/jpeg",
1658 ".jpeg": "image/jpeg",
1659 ".png": "image/png",
1660 ".webp": "image/webp",
1661 ".gif": "image/gif",
1662 ".bmp": "image/bmp",
1663 }.get(extension, "image/jpeg")
1664
1665 encoded = base64.b64encode(data).decode("ascii")
1666 return f"data:{mime_type};base64,{encoded}"
1667
1668 async def _rewrite_i2v_prompt_with_image(
1669 self,
1670 text: str,
1671 condition_image_url: str,
1672 story_profile: dict[str, Any] | None = None,
1673 *,
1674 is_first_frame: bool = False,
1675 enforce_first_frame_continuity: bool = False,
1676 ) -> str:
1677 """Ground an existing Director caption in an I2V condition image."""
1678 if self._provider is None or not self._model:
1679 raise RuntimeError("I2V caption model unavailable")
1680 if not isinstance(condition_image_url, str) or not condition_image_url.strip():
1681 raise RuntimeError("I2V condition image URL is missing")
1682 try:
1683 ordinary_prompt = self._shot_recaption_prompt_path().read_text(
1684 encoding="utf-8"
1685 )
1686 i2v_prompt = self._i2v_prompt_skill_path().read_text(encoding="utf-8")
1687 except OSError as exc:
1688 raise RuntimeError(f"I2V prompt resources unavailable: {exc}") from exc
1689
1690 profile = story_profile if isinstance(story_profile, dict) else {}
1691 language = str(
1692 profile.get("caption_language") or profile.get("language") or ""
1693 )
1694 if is_first_frame and enforce_first_frame_continuity:
1695 mode_instruction = (
1696 "The supplied image is the authoritative first frame (frame 0). "
1697 "First understand every visible character, object, position, pose, "
1698 "environment, composition, camera position, lighting, and action state. "
1699 "Preserve the intended story beat, character IDs, and dialogue, while "
1700 "making the opening physically and temporally continuous from that image."
1701 )
1702 user_instruction = (
1703 "First understand the supplied first-frame image, then rewrite the "
1704 "caption so the action is physically and temporally continuous from it."
1705 )
1706 elif is_first_frame:
1707 mode_instruction = (
1708 "The supplied image is the user-provided first frame. Preserve the "
1709 "caption's story and use the image as its visual starting point."
1710 )
1711 user_instruction = (
1712 "Rewrite the caption for first-frame I2V while preserving its story."
1713 )
1714 else:
1715 mode_instruction = (
1716 "The supplied image is the authoritative previous-shot tail frame. "
1717 "Preserve the story beat while continuing naturally from that frame."
1718 )
1719 user_instruction = (
1720 "Rewrite the caption for I2V continuation from the supplied tail frame."
1721 )
1722
1723 system_prompt = (
1724 f"{ordinary_prompt}\n\n# I2V REWRITE SKILL\n{i2v_prompt}\n\n"
1725 f"{mode_instruction} Return one complete generation caption only. "
1726 f"The caption language is locked to "
1727 f"{language or 'the original caption language'}."
1728 )
1729 user_text = f"{user_instruction}\n\nALREADY-GENERATED CAPTION:\n{text.strip()}"
1730 image_uri = await self._http_download_image_as_base64_uri(
1731 condition_image_url.strip()
1732 )
1733 messages: list[dict[str, Any]] = [
1734 {"role": "system", "content": system_prompt},
1735 {
1736 "role": "user",
1737 "content": [
1738 {"type": "image_url", "image_url": {"url": image_uri}},
1739 {"type": "text", "text": user_text},
1740 ],
1741 },
1742 ]
1743
1744 started = time.monotonic()
1745 response = await self._provider.chat_with_retry(
1746 model=self._model,
1747 messages=messages,
1748 tools=None,
1749 max_tokens=4096,
1750 temperature=0.4,
1751 )
1752 if response.finish_reason == "error":
1753 raise RuntimeError(
1754 f"I2V caption model failed: {response.content or 'unknown error'}"
1755 )
1756 rewritten = " ".join((response.content or "").split()).strip()
1757 if not rewritten:
1758 raise RuntimeError("I2V caption model returned empty output")
1759 language_error = _caption_language_validation_error(rewritten, profile)
1760 if language_error:
1761 retry_messages = [
1762 *messages,
1763 {"role": "assistant", "content": rewritten},
1764 {
1765 "role": "user",
1766 "content": (
1767 "The previous result failed caption-language validation: "
1768 f"{language_error.removeprefix('Error: ')}. Correct only the "
1769 "language issue and return the complete caption without commentary."
1770 ),
1771 },
1772 ]
1773 response = await self._provider.chat_with_retry(
1774 model=self._model,
1775 messages=retry_messages,
1776 tools=None,
1777 max_tokens=4096,
1778 temperature=0.4,
1779 )
1780 if response.finish_reason == "error":
1781 raise RuntimeError(
1782 "I2V caption model failed during language correction: "
1783 f"{response.content or 'unknown error'}"
1784 )
1785 rewritten = " ".join((response.content or "").split()).strip()
1786 language_error = _caption_language_validation_error(rewritten, profile)
1787 if language_error:
1788 raise RuntimeError(language_error.removeprefix("Error: "))
1789 logger.info(
1790 "I2V caption rewrite done model={} elapsed_s={:.2f} caption_chars={}",
1791 self._model,
1792 time.monotonic() - started,
1793 len(rewritten),
1794 )
1795 return rewritten
1796
1797 @staticmethod
1798 def _user_facing_generation_error(error: str) -> str:
1799 text = (error or "").strip()
1800 lowered = text.lower()
1801 if (
1802 "connection refused" in lowered
1803 or "errno 111" in lowered
1804 or UNAVAILABLE_MESSAGE in text
1805 ):
1806 return UNAVAILABLE_MESSAGE
1807 return text[:500]
1808
1809 def _mark_workplace_shot_failed(
1810 self,
1811 session_key: str,
1812 work_id: str,
1813 shot_id: int,
1814 error: str,
1815 ) -> None:
1816 """Persist a Director shot-generation failure for the WebUI."""
1817 try:
1818 chat_id = webui_wire_chat_id(session_key) or "direct"
1819 tool = self._director_generate_tool()
1820 tool.set_context("websocket", chat_id, effective_key=session_key)
1821 state = tool._load_state(work_id)
1822 shot = tool._load_shot(work_id, shot_id)
1823 shot["status"] = "error"
1824 shot["generation_error"] = self._user_facing_generation_error(error)
1825 state["stage"] = "failed"
1826 state["generation_error"] = shot["generation_error"]
1827 tool._save_shot(work_id, shot_id, shot)
1828 shots = state.setdefault("shots", {})
1829 if isinstance(shots, dict):
1830 shots[_shot_key(shot_id)] = tool._state_shot_entry(shot)
1831 tool._save_state(work_id, state)
1832 except Exception:
1833 logger.opt(exception=True).error(
1834 "failed to persist Director generation error work_id={}",
1835 work_id,
1836 )
1837
1838
1839
1840
1841
1842
1843
1844
1845 def _report_echo_unavailable(
1846 self,
1847 session_key: str,
1848 exc: BaseException,
1849 *,
1850 work_id: str | None = None,
1851 shot_id: int | None = None,
1852 ) -> str:
1853 message = UNAVAILABLE_MESSAGE
1854 resolved_work = work_id or self._resolve_work_id_for_session(session_key)
1855 logger.error(
1856 "Echo unavailable session_key={} work_id={} shot_id={} error={}",
1857 session_key,
1858 resolved_work,
1859 shot_id,
1860 exc,
1861 )
1862 if resolved_work:
1863 target_shot = shot_id
1864 if target_shot is None:
1865 for sid, shot in self._iter_workplace_shots(resolved_work):
1866 status = str(shot.get("status") or "")
1867 if status not in {"generated", "review_pass", "approved"}:
1868 target_shot = sid
1869 break
1870 if target_shot is not None:
1871 self._mark_workplace_shot_failed(
1872 session_key, resolved_work, target_shot, message
1873 )
1874 else:
1875 try:
1876 state = self._load_workplace_state(resolved_work)
1877 state["stage"] = "failed"
1878 state["generation_error"] = message
1879 self._save_workplace_state(resolved_work, state)
1880 except Exception:
1881 logger.opt(exception=True).error(
1882 "failed to persist Echo unavailable state work_id={}",
1883 resolved_work,
1884 )
1885 self._schedule_publish_workplace_update(session_key)
1886 return message
1887
1888 def _http_echo_gate_error(
1889 self,
1890 session_key: str,
1891 exc: BaseException,
1892 *,
1893 shot_id: int | None = None,
1894 ) -> Response:
1895 if isinstance(exc, EchoGeneratorUnavailableError) or is_connection_refused(exc):
1896 return _http_error(
1897 503,
1898 self._report_echo_unavailable(session_key, exc, shot_id=shot_id),
1899 )
1900 return _http_error(503, str(exc))
1901
1902
1903
1904
1905
1906 def _envelope_source(self, envelope: dict[str, Any]) -> str | None:
1907 from nanobot.session.source import resolve_source_from_wire
1908 return resolve_source_from_wire(envelope)
1909
1910 def _resolve_work_id_for_session(self, session_key: str) -> str | None:
1911 root = self._director_root()
1912 if root is None:
1913 return None
1914 session_map = self._read_json_file(root / "session_map.json", {})
1915 if not isinstance(session_map, dict):
1916 return None
1917 for key in self._session_map_lookup_keys(session_key):
1918 work_id = self._work_id_from_session_map_entry(session_map.get(key))
1919 if work_id:
1920 return work_id
1921 active = self._read_json_file(root / "active_work.json", {})
1922 if isinstance(active, dict):
1923 active_work_id = active.get("work_id")
1924 if not isinstance(active_work_id, str) or not active_work_id.strip():
1925 return None
1926 active_session = active.get("session_key")
1927 if isinstance(active_session, str) and active_session in self._session_map_lookup_keys(
1928 session_key
1929 ):
1930 return active_work_id.strip()
1931 return None
1932
1933 def _webui_session_key_for_chat(self, chat_id: str) -> str | None:
1934 """Best-effort session key for workplace payloads keyed by wire chat_id."""
1935 for connection in self._subs.get(chat_id, ()):
1936 try:
1937 return self._webui_session_key_for_connection(connection, chat_id)
1938 except ValueError:
1939 continue
1940 root = self._director_root()
1941 if root is None:
1942 return None
1943 session_map = self._read_json_file(root / "session_map.json", {})
1944 if not isinstance(session_map, dict):
1945 return None
1946 suffix = f":{chat_id}"
1947 for map_key in session_map:
1948 if isinstance(map_key, str) and map_key.endswith(suffix):
1949 return map_key
1950 return None
1951
1952 @staticmethod
1953 def _parse_iso_timestamp(raw: Any) -> datetime | None:
1954 if not isinstance(raw, str) or not raw.strip():
1955 return None
1956 try:
1957 return datetime.fromisoformat(raw.replace("Z", "+00:00"))
1958 except ValueError:
1959 return None
1960
1961 @staticmethod
1962 def _format_timeline_time(seconds: float) -> str:
1963 total = max(0, int(round(seconds)))
1964 minutes, secs = divmod(total, 60)
1965 return f"{minutes:02d}:{secs:02d}"
1966
1967 def _estimate_shot_duration_seconds(
1968 self,
1969 shot: dict[str, Any],
1970 goal_duration_s: float,
1971 ) -> float:
1972 goal = {"shot_duration_sec": goal_duration_s} if goal_duration_s > 0 else {}
1973 return resolve_echo_duration_seconds(shot, {"goal": goal})
1974
1975 @staticmethod
1976 def _memory_display_name(memory_id: str) -> str:
1977 """Translate internal memory IDs to user-facing Chinese labels."""
1978 if memory_id.startswith("ID_"):
1979 return "角色_" + memory_id[3:]
1980 if memory_id == "PREVIOUS_SHOT":
1981 return "场景参考"
1982 return memory_id
1983
1984 def _project_memory_selection(
1985 self, raw: Any
1986 ) -> dict[str, Any] | None:
1987 if not isinstance(raw, dict):
1988 return None
1989 memory_id = str(raw.get("memory_id") or raw.get("character_id") or "").strip()
1990 if not memory_id:
1991 return None
1992 projected: dict[str, Any] = {
1993 "memory_id": memory_id,
1994 "display_name": self._memory_display_name(memory_id),
1995 "kind": str(raw.get("kind") or "character"),
1996 "candidate_index": int(raw.get("candidate_index") or 0),
1997 "frame_index": int(raw.get("frame_index") or 0),
1998 "timestamp_sec": float(raw.get("timestamp_sec") or 0.0),
1999 "confidence": float(raw.get("confidence") or 0.0),
2000 "visual_status": str(raw.get("visual_status") or "provisional"),
2001 "reasoning": str(raw.get("reasoning") or ""),
2002 "source_shot_id": int(raw.get("source_shot_id") or 0),
2003 "audio_source_shot_id": int(
2004 raw.get("audio_source_shot_id") or raw.get("source_shot_id") or 0
2005 ),
2006 }
2007 for media_kind in ("image", "audio"):
2008 existing = raw.get(media_kind)
2009 if isinstance(existing, dict):
2010 url = existing.get("url")
2011 if isinstance(url, str):
2012 public = self._public_media_entry(url)
2013 if public is not None:
2014 if existing.get("name"):
2015 public["name"] = str(existing["name"])
2016 projected[media_kind] = public
2017 continue
2018 locator = None
2019 for key in (
2020 f"local_{media_kind}_path",
2021 f"{media_kind}_path",
2022 f"{media_kind}_url",
2023 ):
2024 value = raw.get(key)
2025 if isinstance(value, str) and value.strip():
2026 locator = value.strip()
2027 break
2028 if locator:
2029 public = self._public_media_entry(locator)
2030 if public is not None:
2031 projected[media_kind] = public
2032 return projected
2033
2034 def _project_memory_review(self, raw: Any) -> dict[str, Any] | None:
2035 if not isinstance(raw, dict):
2036 return None
2037 review_id = str(raw.get("review_id") or "").strip()
2038 if not review_id:
2039 return None
2040 selections = [
2041 projected
2042 for item in raw.get("selections", [])
2043 if (projected := self._project_memory_selection(item)) is not None
2044 ]
2045 history: list[dict[str, Any]] = []
2046 for attempt in raw.get("history", []):
2047 if not isinstance(attempt, dict):
2048 continue
2049 history.append(
2050 {
2051 "attempt": int(attempt.get("attempt") or 0),
2052 "selections": [
2053 projected
2054 for item in attempt.get("selections", [])
2055 if (projected := self._project_memory_selection(item)) is not None
2056 ],
2057 "rejected_at": attempt.get("rejected_at"),
2058 }
2059 )
2060 projected_review = {
2061 "review_id": review_id,
2062 "status": str(raw.get("status") or "selecting"),
2063 "attempt": int(raw.get("attempt") or 1),
2064 "candidate_count": int(raw.get("candidate_count") or 0),
2065 "rejected_candidate_indices": [
2066 int(value) for value in raw.get("rejected_candidate_indices", [])
2067 ],
2068 "selections": selections,
2069 "history": history,
2070 "selection_mode": raw.get("selection_mode"),
2071 "required_memory_ids": list(raw.get("required_memory_ids") or []),
2072 "manual_selected_ids": list(raw.get("manual_selected_ids") or []),
2073 "error": raw.get("error"),
2074 "updated_at": raw.get("updated_at"),
2075 }
2076 if "retained_memory_ids" in raw:
2077 projected_review["retained_memory_ids"] = list(
2078 raw.get("retained_memory_ids") or []
2079 )
2080 return projected_review
2081
2082 def _project_memory_bank(self, paths: dict[str, Path]) -> list[dict[str, Any]]:
2083 """Project the work's durable character bank plus current continuity slot."""
2084 bank = self._read_json_file(paths["memory_bank"], {})
2085 entries: list[dict[str, Any]] = []
2086 if isinstance(bank, dict):
2087 for memory_id in sorted(bank):
2088 raw = bank.get(memory_id)
2089 if not isinstance(raw, dict):
2090 continue
2091 projected = self._project_memory_selection(
2092 {
2093 **raw,
2094 "memory_id": str(raw.get("memory_id") or memory_id),
2095 "kind": "character",
2096 }
2097 )
2098 if projected is not None and projected.get("image"):
2099 entries.append(projected)
2100
2101 previous = self._read_json_file(paths["previous_shot_memory"], None)
2102 if isinstance(previous, dict):
2103 projected = self._project_memory_selection(
2104 {
2105 **previous,
2106 "memory_id": "PREVIOUS_SHOT",
2107 "kind": "previous_shot",
2108 }
2109 )
2110 if projected is not None and projected.get("image"):
2111 entries.append(projected)
2112 return entries
2113
2114 @staticmethod
2115 def _memory_workspace_asset_id(raw: dict[str, Any]) -> str:
2116 """Build a stable opaque id for an automatically selected Memory item."""
2117 fingerprint = json.dumps(
2118 [
2119 str(raw.get("memory_id") or raw.get("character_id") or ""),
2120 int(raw.get("source_shot_id") or 0),
2121 int(raw.get("frame_index") or 0),
2122 str(raw.get("kind") or "character"),
2123 ],
2124 ensure_ascii=False,
2125 separators=(",", ":"),
2126 )
2127 return "auto_" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:20]
2128
2129 def _automatic_memory_workspace_records(
2130 self,
2131 paths: dict[str, Path],
2132 ) -> list[tuple[str, dict[str, Any]]]:
2133 """Collect reusable VLM selections and canonical bank items in stable order."""
2134 records: list[tuple[str, dict[str, Any]]] = []
2135 seen: set[str] = set()
2136
2137 def add(raw: Any) -> None:
2138 if not isinstance(raw, dict):
2139 return
2140 projected = self._project_memory_selection(raw)
2141 if projected is None or not projected.get("image"):
2142 return
2143 asset_id = self._memory_workspace_asset_id(raw)
2144 if asset_id in seen:
2145 return
2146 seen.add(asset_id)
2147 records.append((asset_id, raw))
2148
2149 for shot_path in sorted(paths["shots"].glob("shot_*.json")):
2150 shot = self._read_json_file(shot_path, {})
2151 review = shot.get("memory_review") if isinstance(shot, dict) else None
2152 if not isinstance(review, dict):
2153 continue
2154 for selection in review.get("selections", []):
2155 add(selection)
2156
2157 bank = self._read_json_file(paths["memory_bank"], {})
2158 if isinstance(bank, dict):
2159 for memory_id in sorted(bank):
2160 raw = bank.get(memory_id)
2161 if isinstance(raw, dict):
2162 add(
2163 {
2164 **raw,
2165 "memory_id": str(raw.get("memory_id") or memory_id),
2166 "kind": "character",
2167 }
2168 )
2169 previous = self._read_json_file(paths["previous_shot_memory"], None)
2170 if isinstance(previous, dict):
2171 add({**previous, "memory_id": "PREVIOUS_SHOT", "kind": "previous_shot"})
2172 return records
2173
2174 def _load_manual_memory_workspace(self, paths: dict[str, Path]) -> list[dict[str, Any]]:
2175 raw = self._read_json_file(paths["manual_memory_workspace"], {})
2176 assets = raw.get("assets") if isinstance(raw, dict) else None
2177 return [dict(item) for item in assets or [] if isinstance(item, dict)]
2178
2179 def _save_manual_memory_workspace(
2180 self,
2181 paths: dict[str, Path],
2182 assets: list[dict[str, Any]],
2183 ) -> None:
2184 self._write_json_file(
2185 paths["manual_memory_workspace"],
2186 {
2187 "assets": assets,
2188 "updated_at": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
2189 },
2190 )
2191
2192 def _load_memory_asset_profiles(self, paths: dict[str, Path]) -> dict[str, dict[str, Any]]:
2193 raw = self._read_json_file(paths["memory_asset_profiles"], {})
2194 return {
2195 str(asset_id): dict(profile)
2196 for asset_id, profile in raw.items()
2197 if isinstance(asset_id, str) and isinstance(profile, dict)
2198 } if isinstance(raw, dict) else {}
2199
2200 def _save_memory_asset_profiles(
2201 self, paths: dict[str, Path], profiles: dict[str, dict[str, Any]]
2202 ) -> None:
2203 self._write_json_file(paths["memory_asset_profiles"], profiles)
2204
2205 def _project_memory_workspace_assets(
2206 self,
2207 paths: dict[str, Path],
2208 ) -> list[dict[str, Any]]:
2209 assets: list[dict[str, Any]] = []
2210 profile_overrides = self._load_memory_asset_profiles(paths)
2211 for asset_id, raw in self._automatic_memory_workspace_records(paths):
2212 projected = self._project_memory_selection(raw)
2213 if projected is None or not isinstance(projected.get("image"), dict):
2214 continue
2215 asset = {
2216 "asset_id": asset_id,
2217 "display_name": projected.get("display_name") or projected["memory_id"],
2218 "source": "automatic",
2219 "kind": projected.get("kind") or "character",
2220 "memory_id": projected["memory_id"],
2221 "source_shot_id": projected.get("source_shot_id"),
2222 "frame_index": projected.get("frame_index"),
2223 "image": projected["image"],
2224 "audio": projected.get("audio"),
2225 "media_type": "image_audio" if projected.get("audio") else "image",
2226 "profile_text": str(
2227 raw.get("profile_text") or raw.get("reasoning") or ""
2228 ).strip(),
2229 "profile_status": (
2230 "ready"
2231 if str(raw.get("profile_text") or raw.get("reasoning") or "").strip()
2232 else "missing"
2233 ),
2234 "profile_source": str(
2235 raw.get("profile_source")
2236 or ("vlm" if raw.get("reasoning") else "none")
2237 ),
2238 "identity_ids": list(
2239 raw.get("visible_character_ids")
2240 or ([projected["memory_id"]] if str(projected["memory_id"]).startswith("ID_") else [])
2241 ),
2242 "provenance": {
2243 "source": "generated_shot",
2244 "shot_id": projected.get("source_shot_id"),
2245 "timestamp_sec": projected.get("timestamp_sec"),
2246 },
2247 }
2248 override = profile_overrides.get(asset_id)
2249 if isinstance(override, dict):
2250 asset.update({
2251 key: override[key]
2252 for key in (
2253 "profile_text",
2254 "profile_status",
2255 "profile_source",
2256 "identity_ids",
2257 "reference_type",
2258 "reference_label",
2259 )
2260 if key in override
2261 })
2262 assets.append(asset)
2263
2264 for raw in self._load_manual_memory_workspace(paths):
2265 asset_id = str(raw.get("asset_id") or "").strip()
2266 if not asset_id:
2267 continue
2268 projected = self._project_memory_selection(
2269 {
2270 **raw,
2271 "memory_id": asset_id,
2272 "kind": "manual",
2273 }
2274 )
2275 if projected is None or (
2276 not isinstance(projected.get("image"), dict)
2277 and not isinstance(projected.get("audio"), dict)
2278 ):
2279 continue
2280 image = dict(projected["image"]) if isinstance(projected.get("image"), dict) else None
2281 audio = dict(projected["audio"]) if isinstance(projected.get("audio"), dict) else None
2282 if image is not None and raw.get("image_name"):
2283 image["name"] = str(raw["image_name"])
2284 if audio is not None and raw.get("audio_name"):
2285 audio["name"] = str(raw["audio_name"])
2286 assets.append(
2287 {
2288 "asset_id": asset_id,
2289 "display_name": str(
2290 raw.get("display_name")
2291 or (image or {}).get("name")
2292 or (audio or {}).get("name")
2293 or "Local asset"
2294 ),
2295 "source": "local",
2296 "kind": "manual",
2297 **({"image": image} if image is not None else {}),
2298 "audio": audio,
2299 "media_type": (
2300 "image_audio" if image is not None and audio is not None
2301 else "image" if image is not None
2302 else "audio"
2303 ),
2304 "profile_text": str(raw.get("profile_text") or "").strip(),
2305 "profile_status": str(
2306 raw.get("profile_status")
2307 or ("ready" if str(raw.get("profile_text") or "").strip() else "missing")
2308 ),
2309 "profile_source": str(raw.get("profile_source") or "none"),
2310 "identity_ids": [
2311 str(value)
2312 for value in raw.get("identity_ids", [])
2313 if isinstance(value, str) and value.strip()
2314 ],
2315 **(
2316 {"reference_type": str(raw["reference_type"]).strip()}
2317 if str(raw.get("reference_type") or "").strip()
2318 in _MEMORY_REFERENCE_TYPES
2319 else {}
2320 ),
2321 **(
2322 {"reference_label": str(raw["reference_label"]).strip()[:80]}
2323 if str(raw.get("reference_label") or "").strip()
2324 else {}
2325 ),
2326 "provenance": {
2327 "source": (
2328 "generated_shot"
2329 if raw.get("source_shot_id")
2330 else "local_upload"
2331 ),
2332 "shot_id": raw.get("source_shot_id"),
2333 "timestamp_sec": raw.get("timestamp_sec"),
2334 },
2335 }
2336 )
2337 return assets
2338
2339 @staticmethod
2340 def _memory_slot_locator(
2341 raw: dict[str, Any],
2342 media_kind: str,
2343 *,
2344 display: bool,
2345 ) -> str | None:
2346 keys = (
2347 (f"local_{media_kind}_path", f"{media_kind}_path", f"{media_kind}_url")
2348 if display
2349 else (f"{media_kind}_path", f"{media_kind}_url", f"local_{media_kind}_path")
2350 )
2351 for key in keys:
2352 value = raw.get(key)
2353 if isinstance(value, str) and value.strip():
2354 return value.strip()
2355 nested = raw.get(media_kind)
2356 if isinstance(nested, dict):
2357 value = nested.get("url")
2358 if isinstance(value, str) and value.strip():
2359 return value.strip()
2360 return None
2361
2362 def _memory_workspace_slot(
2363 self,
2364 raw: dict[str, Any],
2365 *,
2366 asset_id: str,
2367 source: str,
2368 display: bool,
2369 ) -> dict[str, Any]:
2370 image = self._memory_slot_locator(raw, "image", display=display)
2371 if not image:
2372 raise ValueError("memory asset image is unavailable")
2373 memory_id = str(raw.get("memory_id") or raw.get("character_id") or asset_id)
2374 display_name = str(raw.get("display_name") or self._memory_display_name(memory_id))
2375 reference_type = str(raw.get("reference_type") or "").strip()
2376 reference_label = str(raw.get("reference_label") or "").strip()[:80]
2377 identity_ids = list(dict.fromkeys(
2378 str(value).strip()[:80]
2379 for value in raw.get("identity_ids", [])
2380 if isinstance(value, str) and value.strip()
2381 ))
2382 local_memory_id = reference_label or (identity_ids[0] if identity_ids else asset_id)
2383 metadata: dict[str, Any] = {
2384 "id": memory_id if source == "automatic" else local_memory_id,
2385 "workspace_asset_id": asset_id,
2386 "display_name": display_name,
2387 "source": (
2388 str(raw.get("kind") or "automatic_memory")
2389 if source == "automatic"
2390 else "manual_workspace"
2391 ),
2392 }
2393 if reference_type in _MEMORY_REFERENCE_TYPES:
2394 metadata["reference_type"] = reference_type
2395 if reference_label:
2396 metadata["reference_label"] = reference_label
2397 if identity_ids:
2398 metadata["identity_ids"] = identity_ids
2399 profile_text = str(raw.get("profile_text") or "").strip()[:2000]
2400 if profile_text:
2401 metadata["profile_text"] = profile_text
2402 for key in (
2403 "visual_status",
2404 "source_shot_id",
2405 "frame_index",
2406 "timestamp_sec",
2407 "confidence",
2408 "audio_source_shot_id",
2409 ):
2410 if key in raw:
2411 metadata[key] = raw[key]
2412 slot: dict[str, Any] = {"image_url": image, "metadata": metadata}
2413 audio = self._memory_slot_locator(raw, "audio", display=display)
2414 if audio:
2415 slot["audio_url"] = audio
2416 else:
2417 slot["audio_mode"] = "empty"
2418 return slot
2419
2420 def _persist_memory_asset_upload(
2421 self,
2422 *,
2423 work_id: str,
2424 asset_id: str,
2425 media_kind: str,
2426 payload: Any,
2427 ) -> dict[str, str]:
2428 if not isinstance(payload, dict):
2429 raise ValueError(f"{media_kind} upload is malformed")
2430 data_url = payload.get("data_url")
2431 if not isinstance(data_url, str) or not data_url:
2432 raise ValueError(f"{media_kind} data_url is required")
2433 mime = _extract_data_url_mime(data_url)
2434 allowed = _IMAGE_MIME_ALLOWED if media_kind == "image" else _AUDIO_MIME_ALLOWED
2435 if mime not in allowed:
2436 raise ValueError(f"unsupported {media_kind} type")
2437 limit = _MAX_IMAGE_BYTES if media_kind == "image" else _MAX_MEMORY_AUDIO_BYTES
2438 temp_path: Path | None = None
2439 try:
2440 saved = save_base64_data_url(data_url, get_media_dir("websocket"), max_bytes=limit)
2441 if saved is None:
2442 raise ValueError(f"invalid {media_kind} data")
2443 temp_path = Path(saved)
2444 publisher = configured_file_publisher(
2445 work_id,
2446 storage=self._tools_config.file_storage,
2447 workspace=self._session_manager.workspace,
2448 )
2449 public_url = publisher(
2450 str(temp_path),
2451 f"memory/manual/{asset_id}/{media_kind}{temp_path.suffix}",
2452 )
2453 local = resolve_local_asset_path(
2454 public_url,
2455 workspace=self._session_manager.workspace,
2456 config=self._tools_config.file_storage.local,
2457 )
2458 result = {f"{media_kind}_path": public_url}
2459 if local is not None:
2460 result[f"local_{media_kind}_path"] = str(local)
2461 name = payload.get("name")
2462 if isinstance(name, str) and name.strip():
2463 result[f"{media_kind}_name"] = Path(name.strip()).name[:160]
2464 return result
2465 except FileSizeExceeded as exc:
2466 raise ValueError(f"{media_kind} exceeds size limit") from exc
2467 finally:
2468 if temp_path is not None:
2469 temp_path.unlink(missing_ok=True)
2470
2471 def _apply_workplace_memory_asset_save(
2472 self,
2473 session_key: str,
2474 payload: Any,
2475 ) -> tuple[str, dict[str, Any]]:
2476 if not isinstance(payload, dict):
2477 raise ValueError("asset is required")
2478 work_id = self._resolve_work_id_for_session(session_key)
2479 if not work_id:
2480 raise ValueError("workplace is not initialized")
2481 paths = self._workplace_paths(work_id)
2482 if paths is None:
2483 raise ValueError("workplace is unavailable")
2484 assets = self._load_manual_memory_workspace(paths)
2485 requested_id = str(payload.get("asset_id") or "").strip()
2486 existing_index = next(
2487 (index for index, item in enumerate(assets) if item.get("asset_id") == requested_id),
2488 None,
2489 ) if requested_id else None
2490 if requested_id and existing_index is None:
2491 automatic_ids = {
2492 asset_id for asset_id, _raw in self._automatic_memory_workspace_records(paths)
2493 }
2494 if requested_id not in automatic_ids:
2495 raise ValueError("memory asset not found")
2496 if any(payload.get(key) is not None for key in ("image", "audio")):
2497 raise ValueError("automatic asset media cannot be replaced")
2498 profiles = self._load_memory_asset_profiles(paths)
2499 profile_text = str(payload.get("profile_text") or "").strip()[:2000]
2500 identities = payload.get("identity_ids")
2501 existing_profile = profiles.get(requested_id)
2502 saved_profile = dict(existing_profile) if isinstance(existing_profile, dict) else {}
2503 saved_profile.update({
2504 "profile_text": profile_text,
2505 "profile_status": "ready" if profile_text else "missing",
2506 "profile_source": "human" if profile_text else "none",
2507 "updated_at": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
2508 })
2509 self._apply_memory_reference_fields(saved_profile, payload)
2510 if isinstance(identities, list):
2511 saved_profile["identity_ids"] = list(dict.fromkeys(
2512 str(value).strip()[:80]
2513 for value in identities
2514 if isinstance(value, str) and value.strip()
2515 ))
2516 elif "reference_type" in payload or "reference_label" in payload:
2517 if (
2518 saved_profile.get("reference_type") == "character"
2519 and saved_profile.get("reference_label")
2520 ):
2521 saved_profile["identity_ids"] = [saved_profile["reference_label"]]
2522 elif saved_profile.get("reference_type") != "character":
2523 saved_profile["identity_ids"] = []
2524 profiles[requested_id] = saved_profile
2525 self._save_memory_asset_profiles(paths, profiles)
2526 return work_id, self._build_workplace_payload(session_key)
2527 if existing_index is None and len(assets) >= _MAX_MEMORY_WORKSPACE_ASSETS:
2528 raise ValueError("memory workspace is full")
2529 asset_id = requested_id or uuid.uuid4().hex
2530 now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
2531 asset = dict(assets[existing_index]) if existing_index is not None else {
2532 "asset_id": asset_id,
2533 "created_at": now,
2534 }
2535 image_payload = payload.get("image")
2536 if image_payload is not None:
2537 asset.update(
2538 self._persist_memory_asset_upload(
2539 work_id=work_id,
2540 asset_id=asset_id,
2541 media_kind="image",
2542 payload=image_payload,
2543 )
2544 )
2545 audio_payload = payload.get("audio")
2546 if audio_payload is not None:
2547 asset.update(
2548 self._persist_memory_asset_upload(
2549 work_id=work_id,
2550 asset_id=asset_id,
2551 media_kind="audio",
2552 payload=audio_payload,
2553 )
2554 )
2555 if not asset.get("image_path") and not asset.get("audio_path"):
2556 raise ValueError("new memory asset requires image or audio")
2557 if payload.get("remove_audio") is True:
2558 for key in ("audio_path", "local_audio_path", "audio_name"):
2559 asset.pop(key, None)
2560 if not asset.get("image_path"):
2561 raise ValueError("delete an audio-only asset instead of removing its media")
2562 display_name = payload.get("display_name")
2563 if isinstance(display_name, str) and display_name.strip():
2564 asset["display_name"] = display_name.strip()[:120]
2565 elif not asset.get("display_name"):
2566 asset["display_name"] = str(asset.get("image_name") or "Local asset")
2567 if "profile_text" in payload:
2568 profile_text = str(payload.get("profile_text") or "").strip()[:2000]
2569 asset["profile_text"] = profile_text
2570 asset["profile_status"] = "ready" if profile_text else "missing"
2571 asset["profile_source"] = "human" if profile_text else "none"
2572 self._apply_memory_reference_fields(asset, payload)
2573 identity_ids = payload.get("identity_ids")
2574 if isinstance(identity_ids, list):
2575 asset["identity_ids"] = list(dict.fromkeys(
2576 str(value).strip()[:80]
2577 for value in identity_ids
2578 if isinstance(value, str) and value.strip()
2579 ))
2580 elif "reference_type" in payload or "reference_label" in payload:
2581 if asset.get("reference_type") == "character" and asset.get("reference_label"):
2582 asset["identity_ids"] = [asset["reference_label"]]
2583 elif asset.get("reference_type") != "character":
2584 asset["identity_ids"] = []
2585 elif image_payload is not None and asset.get("profile_source") != "human":
2586 # The configured Memory VLM may profile local image uploads. If no
2587 # VLM route exists, keep the asset usable by humans but invisible
2588 # to agent recommendation until a profile is entered manually.
2589 runner_options = getattr(self._memory_review_runner, "keywords", {})
2590 runner_options = runner_options if isinstance(runner_options, dict) else {}
2591 api_base = str(runner_options.get("api_base") or "").strip()
2592 api_key = str(runner_options.get("api_key") or "").strip()
2593 model = str(runner_options.get("vlm_model") or "").strip()
2594 local_image = str(asset.get("local_image_path") or "").strip()
2595 if api_base and api_key and model and local_image:
2596 try:
2597 from nanobot.director.memory_selector import MemoryVlmSelector
2598
2599 generated = MemoryVlmSelector(
2600 api_base=api_base,
2601 api_key=api_key,
2602 model=model,
2603 ).profile_image(
2604 image_path=Path(local_image),
2605 display_name=str(asset.get("display_name") or ""),
2606 )
2607 asset["profile_text"] = generated["profile_text"]
2608 asset["identity_ids"] = generated["identity_ids"]
2609 asset["profile_status"] = "ready"
2610 asset["profile_source"] = "vlm"
2611 except Exception as exc:
2612 logger.warning(
2613 "memory asset VLM profile failed work_id={} asset_id={} error={}",
2614 work_id,
2615 asset_id,
2616 exc,
2617 )
2618 asset["profile_status"] = "error"
2619 asset["profile_source"] = "none"
2620 else:
2621 asset["profile_status"] = "missing"
2622 asset["profile_source"] = "none"
2623 asset["updated_at"] = now
2624 if existing_index is None:
2625 assets.append(asset)
2626 else:
2627 assets[existing_index] = asset
2628 self._save_manual_memory_workspace(paths, assets)
2629 return work_id, self._build_workplace_payload(session_key)
2630
2631 def _apply_workplace_shot_memory_asset_create(
2632 self,
2633 session_key: str,
2634 shot_id: int,
2635 payload: Any,
2636 ) -> tuple[str, dict[str, Any]]:
2637 """Save one user-chosen frame and optional audio clip from a shot."""
2638 if not isinstance(payload, dict):
2639 raise ValueError("asset is required")
2640 loaded = self._load_workplace_shot(session_key, shot_id)
2641 if loaded is None:
2642 raise ValueError(f"shot {shot_id} not found")
2643 work_id, _shot_path, _state, shot = loaded
2644 paths = self._workplace_paths(work_id)
2645 if paths is None:
2646 raise ValueError("workplace is unavailable")
2647 assets = self._load_manual_memory_workspace(paths)
2648 if len(assets) >= _MAX_MEMORY_WORKSPACE_ASSETS:
2649 raise ValueError("memory workspace is full")
2650
2651 reference_type = str(payload.get("reference_type") or "").strip().lower()
2652 if reference_type not in _MEMORY_REFERENCE_TYPES:
2653 raise ValueError("reference_type is required")
2654 reference_label = str(payload.get("reference_label") or "").strip()[:80]
2655 profile_text = str(payload.get("profile_text") or "").strip()[:2000]
2656 try:
2657 timestamp_sec = float(payload.get("timestamp_sec"))
2658 except (TypeError, ValueError) as exc:
2659 raise ValueError("timestamp_sec is required") from exc
2660 if timestamp_sec < 0:
2661 raise ValueError("timestamp_sec must be non-negative")
2662
2663 include_audio = payload.get("include_audio") is True
2664 audio_start_sec: float | None = None
2665 audio_end_sec: float | None = None
2666 if include_audio:
2667 try:
2668 audio_start_sec = float(payload.get("audio_start_sec"))
2669 audio_end_sec = float(payload.get("audio_end_sec"))
2670 except (TypeError, ValueError) as exc:
2671 raise ValueError("audio start and end are required") from exc
2672 if audio_start_sec < 0 or audio_end_sec <= audio_start_sec:
2673 raise ValueError("audio end must be after audio start")
2674 if audio_end_sec - audio_start_sec > 30:
2675 raise ValueError("memory audio clip cannot exceed 30 seconds")
2676
2677 artifact = str(
2678 shot.get("artifact_url") or shot.get("artifact_path") or ""
2679 ).strip()
2680 if not artifact:
2681 raise ValueError(f"shot {shot_id} has no video artifact")
2682 video_path = (
2683 paths["work_dir"] / "memory" / "videos" / f"shot_{shot_id:03d}.mp4"
2684 )
2685 if not video_path.is_file():
2686 download_video(artifact, video_path)
2687
2688 asset_id = uuid.uuid4().hex
2689 asset_dir = paths["work_dir"] / "memory" / "manual" / "assets" / asset_id
2690 image_path = asset_dir / "image.jpg"
2691 selected_time, frame_index = extract_video_frame(
2692 video_path, image_path, timestamp_sec
2693 )
2694 audio_path: Path | None = None
2695 if include_audio and audio_start_sec is not None and audio_end_sec is not None:
2696 audio_path = asset_dir / "audio.wav"
2697 audio_path.parent.mkdir(parents=True, exist_ok=True)
2698 result = subprocess.run(
2699 [
2700 _resolve_media_binary("ffmpeg"),
2701 "-loglevel", "error", "-y",
2702 "-ss", f"{audio_start_sec:.6f}",
2703 "-i", str(video_path),
2704 "-t", f"{audio_end_sec - audio_start_sec:.6f}",
2705 "-vn", "-acodec", "pcm_s16le", "-ar", "48000",
2706 str(audio_path),
2707 ],
2708 capture_output=True,
2709 text=True,
2710 )
2711 if (
2712 result.returncode != 0
2713 or not audio_path.is_file()
2714 or audio_path.stat().st_size <= 44
2715 ):
2716 raise ValueError(
2717 f"failed to extract memory audio: {result.stderr[:1000]}"
2718 )
2719 if audio_path.stat().st_size > _MAX_MEMORY_AUDIO_BYTES:
2720 raise ValueError("memory audio exceeds size limit")
2721
2722 profile_status = "ready" if profile_text else "missing"
2723 profile_source = "human" if profile_text else "none"
2724 identities = (
2725 [reference_label]
2726 if reference_type == "character" and reference_label
2727 else []
2728 )
2729 if not profile_text:
2730 runner_options = getattr(self._memory_review_runner, "keywords", {})
2731 runner_options = runner_options if isinstance(runner_options, dict) else {}
2732 api_base = str(runner_options.get("api_base") or "").strip()
2733 api_key = str(runner_options.get("api_key") or "").strip()
2734 model = str(runner_options.get("vlm_model") or "").strip()
2735 if api_base and api_key and model:
2736 try:
2737 from nanobot.director.memory_selector import MemoryVlmSelector
2738
2739 generated = MemoryVlmSelector(
2740 api_base=api_base,
2741 api_key=api_key,
2742 model=model,
2743 ).profile_image(
2744 image_path=image_path,
2745 display_name=reference_label or f"Shot {shot_id} reference",
2746 )
2747 profile_text = generated["profile_text"]
2748 if reference_type == "character" and not identities:
2749 identities = generated["identity_ids"]
2750 profile_status = "ready"
2751 profile_source = "vlm"
2752 except Exception as exc:
2753 logger.warning(
2754 "shot memory asset VLM profile failed work_id={} shot_id={} error={}",
2755 work_id,
2756 shot_id,
2757 exc,
2758 )
2759 profile_status = "error"
2760
2761 publisher = configured_file_publisher(
2762 work_id,
2763 storage=self._tools_config.file_storage,
2764 workspace=self._session_manager.workspace,
2765 )
2766 image_url = publisher(
2767 str(image_path), f"memory/manual/{asset_id}/image.jpg"
2768 )
2769 audio_url = (
2770 publisher(str(audio_path), f"memory/manual/{asset_id}/audio.wav")
2771 if audio_path is not None
2772 else None
2773 )
2774 local_image = resolve_local_asset_path(
2775 image_url,
2776 workspace=self._session_manager.workspace,
2777 config=self._tools_config.file_storage.local,
2778 )
2779 local_audio = (
2780 resolve_local_asset_path(
2781 audio_url,
2782 workspace=self._session_manager.workspace,
2783 config=self._tools_config.file_storage.local,
2784 )
2785 if audio_url is not None
2786 else None
2787 )
2788 now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
2789 asset: dict[str, Any] = {
2790 "asset_id": asset_id,
2791 "display_name": reference_label or f"Shot {shot_id} {reference_type}",
2792 "image_path": image_url,
2793 "image_name": f"shot_{shot_id:03d}_{selected_time:.2f}s.jpg",
2794 "source_shot_id": shot_id,
2795 "timestamp_sec": selected_time,
2796 "frame_index": frame_index,
2797 "reference_type": reference_type,
2798 "reference_label": reference_label,
2799 "profile_text": profile_text,
2800 "profile_status": profile_status,
2801 "profile_source": profile_source,
2802 "identity_ids": identities,
2803 "created_at": now,
2804 "updated_at": now,
2805 }
2806 if local_image is not None:
2807 asset["local_image_path"] = str(local_image)
2808 if audio_url is not None:
2809 asset["audio_path"] = audio_url
2810 asset["audio_name"] = (
2811 f"shot_{shot_id:03d}_{audio_start_sec:.2f}-{audio_end_sec:.2f}s.wav"
2812 )
2813 asset["audio_start_sec"] = audio_start_sec
2814 asset["audio_end_sec"] = audio_end_sec
2815 asset["audio_source_shot_id"] = shot_id
2816 if local_audio is not None:
2817 asset["local_audio_path"] = str(local_audio)
2818 assets.append(asset)
2819 self._save_manual_memory_workspace(paths, assets)
2820 return work_id, self._build_workplace_payload(session_key)
2821
2822 @staticmethod
2823 def _apply_memory_reference_fields(
2824 target: dict[str, Any], payload: dict[str, Any]
2825 ) -> None:
2826 if "reference_type" in payload:
2827 reference_type = str(payload.get("reference_type") or "").strip().lower()
2828 if reference_type and reference_type not in _MEMORY_REFERENCE_TYPES:
2829 raise ValueError("invalid memory reference type")
2830 if reference_type:
2831 target["reference_type"] = reference_type
2832 else:
2833 target.pop("reference_type", None)
2834 if "reference_label" in payload:
2835 reference_label = str(payload.get("reference_label") or "").strip()[:80]
2836 if reference_label:
2837 target["reference_label"] = reference_label
2838 else:
2839 target.pop("reference_label", None)
2840
2841 def _apply_workplace_memory_asset_delete(
2842 self,
2843 session_key: str,
2844 asset_id: str,
2845 ) -> tuple[str, dict[str, Any]]:
2846 work_id = self._resolve_work_id_for_session(session_key)
2847 if not work_id:
2848 raise ValueError("workplace is not initialized")
2849 paths = self._workplace_paths(work_id)
2850 if paths is None:
2851 raise ValueError("workplace is unavailable")
2852 assets = self._load_manual_memory_workspace(paths)
2853 kept = [item for item in assets if str(item.get("asset_id") or "") != asset_id]
2854 if len(kept) == len(assets):
2855 raise ValueError("memory asset not found")
2856 for shot_path in paths["shots"].glob("shot_*.json"):
2857 shot = self._read_json_file(shot_path, {})
2858 if not isinstance(shot, dict):
2859 continue
2860 for slot in shot.get("approved_memory_slots", []):
2861 metadata = slot.get("metadata") if isinstance(slot, dict) else None
2862 if (
2863 isinstance(metadata, dict)
2864 and asset_id in {
2865 metadata.get("workspace_asset_id"),
2866 metadata.get("audio_workspace_asset_id"),
2867 }
2868 ):
2869 raise ValueError("remove this asset from shot slots before deleting it")
2870 # Cached media stays on disk because an already-applied shot may still reference it.
2871 self._save_manual_memory_workspace(paths, kept)
2872 return work_id, self._build_workplace_payload(session_key)
2873
2874 def _apply_workplace_shot_memory_slots_save(
2875 self,
2876 session_key: str,
2877 shot_id: int,
2878 refs: Any,
2879 ) -> tuple[str, dict[str, Any]]:
2880 if not isinstance(refs, list):
2881 raise ValueError("slots must be a list")
2882 if len(refs) > _MAX_MEMORY_SLOTS:
2883 raise ValueError("a shot supports at most 7 memory slots")
2884 loaded = self._load_workplace_shot(session_key, shot_id)
2885 if loaded is None:
2886 raise ValueError(f"shot {shot_id} not found")
2887 work_id, shot_path, state, shot = loaded
2888 if (
2889 shot_id > 1
2890 and str(state.get("stage") or "") == "awaiting_memory_build"
2891 and not shot.get("memory_slots_user_configured")
2892 and str(shot.get("memory_recommendation_source") or "") != "agent"
2893 ):
2894 raise ValueError("Memory recommendation is still in progress")
2895 paths = self._workplace_paths(work_id)
2896 if paths is None:
2897 raise ValueError("workplace is unavailable")
2898 profile_overrides = self._load_memory_asset_profiles(paths)
2899 automatic = {
2900 asset_id: {
2901 **raw,
2902 **(
2903 profile_overrides.get(asset_id)
2904 if isinstance(profile_overrides.get(asset_id), dict)
2905 else {}
2906 ),
2907 }
2908 for asset_id, raw in self._automatic_memory_workspace_records(paths)
2909 }
2910 manual = {
2911 str(item.get("asset_id") or ""): item
2912 for item in self._load_manual_memory_workspace(paths)
2913 if item.get("asset_id")
2914 }
2915 slots: list[dict[str, Any]] = []
2916 display_slots: list[dict[str, Any]] = []
2917 saved_refs: list[dict[str, Any]] = []
2918 seen: set[str] = set()
2919 for ref in refs:
2920 if not isinstance(ref, dict):
2921 raise ValueError("memory slot is malformed")
2922 source = str(ref.get("source") or "")
2923 asset_id = str(
2924 ref.get("image_asset_id") or ref.get("asset_id") or ""
2925 ).strip()
2926 audio_asset_id = str(ref.get("audio_asset_id") or "").strip() or None
2927 if not source and asset_id:
2928 source = "automatic" if asset_id in automatic else "local"
2929 if source not in {"automatic", "local"} or not asset_id:
2930 raise ValueError("memory slot reference is invalid")
2931 if asset_id in seen:
2932 raise ValueError("memory slots cannot contain duplicates")
2933 seen.add(asset_id)
2934 raw = automatic.get(asset_id) if source == "automatic" else manual.get(asset_id)
2935 if raw is None:
2936 raise ValueError(f"memory asset {asset_id} not found")
2937 slot = self._memory_workspace_slot(
2938 raw, asset_id=asset_id, source=source, display=False
2939 )
2940 display_slot = self._memory_workspace_slot(
2941 raw, asset_id=asset_id, source=source, display=True
2942 )
2943 if audio_asset_id:
2944 audio_source = (
2945 "automatic" if audio_asset_id in automatic
2946 else "local" if audio_asset_id in manual
2947 else ""
2948 )
2949 audio_raw = (
2950 automatic.get(audio_asset_id)
2951 if audio_source == "automatic"
2952 else manual.get(audio_asset_id)
2953 )
2954 if audio_raw is None:
2955 raise ValueError(f"memory audio asset {audio_asset_id} not found")
2956 audio = self._memory_slot_locator(audio_raw, "audio", display=False)
2957 display_audio = self._memory_slot_locator(audio_raw, "audio", display=True)
2958 if not audio:
2959 raise ValueError(f"memory asset {audio_asset_id} has no audio")
2960 slot["audio_url"] = audio
2961 slot.pop("audio_mode", None)
2962 display_slot["audio_url"] = display_audio or audio
2963 display_slot.pop("audio_mode", None)
2964 slot["metadata"]["audio_workspace_asset_id"] = audio_asset_id
2965 display_slot["metadata"]["audio_workspace_asset_id"] = audio_asset_id
2966 slots.append(slot)
2967 display_slots.append(display_slot)
2968 saved_refs.append({
2969 "image_asset_id": asset_id,
2970 **({"audio_asset_id": audio_asset_id} if audio_asset_id else {}),
2971 })
2972 shot["approved_memory_slots"] = slots
2973 shot["approved_memory_display_slots"] = display_slots
2974 shot["approved_memory_slot_refs"] = saved_refs
2975 shot["memory_slots_user_configured"] = True
2976 shot["memory_slots_applied_at"] = (
2977 datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
2978 )
2979 self._save_workplace_shot(shot_path, shot)
2980 return work_id, self._build_workplace_payload(session_key)
2981
2982 def _project_generation_memory(self, raw: Any) -> dict[str, Any] | None:
2983 """Project one approved R2V Memory slot without exposing local paths."""
2984 if not isinstance(raw, dict):
2985 return None
2986 metadata = raw.get("metadata")
2987 metadata = metadata if isinstance(metadata, dict) else {}
2988 memory_id = str(metadata.get("id") or raw.get("memory_id") or "").strip()
2989 if not memory_id:
2990 return None
2991
2992 def public_ref(value: Any) -> dict[str, str] | None:
2993 if not isinstance(value, str) or not value.strip():
2994 return None
2995 locator = value.strip()
2996 if locator.startswith("/api/media/"):
2997 return {"url": locator}
2998 return self._public_media_entry(locator)
2999
3000 image = public_ref(raw.get("image_url") or raw.get("image_path"))
3001 if image is None:
3002 return None
3003 projected: dict[str, Any] = {
3004 "id": memory_id,
3005 "display_name": str(
3006 metadata.get("display_name") or self._memory_display_name(memory_id)
3007 ),
3008 "image": image,
3009 "metadata": {
3010 key: metadata[key]
3011 for key in (
3012 "source",
3013 "visual_status",
3014 "source_shot_id",
3015 "frame_index",
3016 "timestamp_sec",
3017 "confidence",
3018 "audio_source_shot_id",
3019 "audio_workspace_asset_id",
3020 "reference_type",
3021 "reference_label",
3022 "identity_ids",
3023 "profile_text",
3024 )
3025 if key in metadata
3026 },
3027 }
3028 workspace_asset_id = metadata.get("workspace_asset_id")
3029 if isinstance(workspace_asset_id, str) and workspace_asset_id.strip():
3030 projected["workspace_asset_id"] = workspace_asset_id.strip()
3031 audio = public_ref(raw.get("audio_url") or raw.get("audio_path"))
3032 if audio is not None:
3033 projected["audio"] = audio
3034 return projected
3035
3036 def _build_workplace_payload(self, session_key: str) -> dict[str, Any]:
3037 payload: dict[str, Any] = {
3038 "session_key": session_key,
3039 "work_id": None,
3040 "story_md": "",
3041 "story_empty": True,
3042 "stage": None,
3043 "goal": {},
3044 "final_output_path": None,
3045 "final_output_url": None,
3046 "final_video": None,
3047 "memory_bank": [],
3048 "memory_workspace_assets": [],
3049 "shots": [],
3050 "updated_at": None,
3051 }
3052 work_id = self._resolve_work_id_for_session(session_key)
3053 if not work_id:
3054 return self._attach_session_reference_and_auto(session_key, payload)
3055 paths = self._workplace_paths(work_id)
3056 if paths is None:
3057 return payload
3058 state = self._read_json_file(paths["state"], {})
3059 if not isinstance(state, dict):
3060 state = {}
3061 self._reconcile_workplace_state_shots(work_id, state)
3062 story_profile = self._load_workplace_story_profile(work_id)
3063 beats = story_profile.get("beats") if isinstance(story_profile.get("beats"), list) else []
3064 beat_count = len(beats)
3065 if beat_count > 0:
3066 self._prune_workplace_shots_beyond_count(work_id, beat_count, state)
3067 self._reconcile_workplace_state_shots(work_id, state)
3068 stage = str(state.get("stage") or "")
3069 if stage == "shot_generating":
3070 self._ensure_workplace_shot_references(work_id)
3071 story_md = ""
3072 try:
3073 if paths["story"].exists():
3074 story_md = paths["story"].read_text(encoding="utf-8")
3075 except OSError:
3076 story_md = ""
3077 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
3078 goal_duration_s = float(goal.get("shot_duration_sec") or 0)
3079 final_locator = None
3080 for candidate in (state.get("final_output_url"), state.get("final_output_path")):
3081 if isinstance(candidate, str) and candidate.strip():
3082 final_locator = candidate.strip()
3083 break
3084 final_video = self._public_media_entry(final_locator) if isinstance(final_locator, str) else None
3085 if (
3086 final_video is None
3087 and isinstance(final_locator, str)
3088 and final_locator
3089 and not urlparse(final_locator).scheme
3090 ):
3091 relative_candidate = Path(final_locator)
3092 if not relative_candidate.is_absolute():
3093 for base_dir in (paths["work_dir"], paths["work_dir"] / "outputs"):
3094 candidate_entry = self._public_media_entry(str(base_dir / relative_candidate))
3095 if candidate_entry is not None:
3096 final_video = candidate_entry
3097 break
3098 shot_rows: list[dict[str, Any]] = []
3099 cursor_s = 0.0
3100 for shot_path in sorted(paths["shots"].glob("shot_*.json")):
3101 shot = self._read_json_file(shot_path, {})
3102 if not isinstance(shot, dict):
3103 continue
3104 try:
3105 shot_id = int(shot.get("shot_id"))
3106 except (TypeError, ValueError):
3107 continue
3108 duration_s = self._estimate_shot_duration_seconds(shot, goal_duration_s)
3109 media_entry = None
3110 for candidate in (
3111 shot.get("artifact_url"),
3112 shot.get("artifact_path"),
3113 shot.get("remote_result", {}).get("video_path")
3114 if isinstance(shot.get("remote_result"), dict)
3115 else None,
3116 ):
3117 if not isinstance(candidate, str) or not candidate.strip():
3118 continue
3119 media_entry = self._public_media_entry(candidate)
3120 if media_entry is not None:
3121 break
3122 echo_data = shot.get("echo") if isinstance(shot.get("echo"), dict) else {}
3123 shot_updated = self._parse_iso_timestamp(shot.get("updated_at"))
3124 timeline_start = cursor_s
3125 timeline_end = cursor_s + duration_s
3126 cursor_s = timeline_end
3127 planned_reference_shot_ids = self._planned_reference_shot_ids(shot)
3128 projected_memory_review = self._project_memory_review(
3129 shot.get("memory_review")
3130 )
3131 if projected_memory_review is not None and media_entry is not None:
3132 projected_memory_review["source_video"] = media_entry
3133 projected_slots = [
3134 projected
3135 for item in (
3136 shot.get("approved_memory_display_slots")
3137 or shot.get("approved_memory_slots", [])
3138 )
3139 if (projected := self._project_generation_memory(item)) is not None
3140 ]
3141 projected_recommended_slots = [
3142 projected
3143 for item in (
3144 shot.get("recommended_memory_display_slots")
3145 or shot.get("recommended_memory_slots", [])
3146 )
3147 if (projected := self._project_generation_memory(item)) is not None
3148 ]
3149 recommendation_refs = shot.get("recommended_memory_slot_refs")
3150 if not isinstance(recommendation_refs, list):
3151 recommendation_refs = [
3152 {
3153 "image_asset_id": item["workspace_asset_id"],
3154 "reason": "Matched from the accepted shot's visual continuity profile.",
3155 }
3156 for item in projected_recommended_slots
3157 if isinstance(item.get("workspace_asset_id"), str)
3158 ]
3159 shot_rows.append(
3160 {
3161 "shot_id": shot_id,
3162 "shot_key": shot.get("shot_key") or f"shot_{shot_id:03d}",
3163 "status": str(shot.get("status") or "planned"),
3164 "summary": shot.get("summary") or "",
3165 "caption": shot.get("caption") or "",
3166 "num_frames": shot.get("num_frames"),
3167 "cut": bool(shot.get("cut", True)),
3168 "video": media_entry,
3169 "has_video": media_entry is not None,
3170 "last_review": shot.get("last_review"),
3171 "review_notes": shot.get("review_notes") or "",
3172 "generation_error": shot.get("generation_error") or "",
3173 "memory_review": projected_memory_review,
3174 "generation_memories": projected_slots,
3175 "memory_slots": projected_slots,
3176 "memory_slots_configured": bool(shot.get("memory_slots_user_configured")),
3177 "approved_memory_slot_refs": list(
3178 shot.get("approved_memory_slot_refs") or []
3179 ),
3180 "recommended_memory_slots": projected_recommended_slots,
3181 "recommended_memory_slot_refs": recommendation_refs,
3182 "memory_recommendation_source": shot.get("memory_recommendation_source"),
3183 "version_id": echo_data.get("version_id") or "",
3184 "echo_status": echo_data.get("status") or "",
3185 "updated_at": shot.get("updated_at"),
3186 "planned_reference_shot_ids": planned_reference_shot_ids,
3187 "reference_shot_ids": planned_reference_shot_ids,
3188 "reference_selection_note": str(shot.get("reference_selection_note") or ""),
3189 "references_planned": "planned_reference_shot_ids" in shot,
3190 "continuous_enabled": bool(shot.get("continuous_enabled", False)),
3191 "tail_frame_url": shot.get("tail_frame_url") or "",
3192 "timeline": {
3193 "start_seconds": timeline_start,
3194 "end_seconds": timeline_end,
3195 "duration_seconds": duration_s,
3196 "label": (
3197 f"{self._format_timeline_time(timeline_start)} - "
3198 f"{self._format_timeline_time(timeline_end)}"
3199 ),
3200 },
3201 "has_actions": media_entry is not None,
3202 "accepted": str(shot.get("status") or "") in {"review_pass", "approved"},
3203 }
3204 )
3205 if shot_updated is not None:
3206 current_updated = self._parse_iso_timestamp(payload["updated_at"])
3207 if current_updated is None or shot_updated > current_updated:
3208 payload["updated_at"] = shot.get("updated_at")
3209 beats_editable = self._workplace_beats_editable(work_id, state)
3210 self._sync_story_confirmed_when_shot_specs_ready(
3211 work_id,
3212 state,
3213 beat_count=beat_count,
3214 )
3215 payload.update(
3216 {
3217 "work_id": work_id,
3218 "story_md": story_md,
3219 "story_empty": story_md.strip() == "",
3220 "story_profile": story_profile,
3221 "story_editable": stage == "story_discussion",
3222 "beats_editable": beats_editable,
3223 "story_confirmed": bool(state.get("story_confirmed")),
3224 "shot_prompts_ready": self._workplace_shot_prompts_ready(
3225 work_id,
3226 state,
3227 beat_count=beat_count,
3228 ),
3229 "shot_prompts_progress": self._workplace_shot_prompts_progress(
3230 work_id,
3231 beat_count=beat_count,
3232 ),
3233 "references_ready": self._workplace_references_ready(work_id, beat_count=beat_count),
3234 "shot_generating_started_at": state.get("shot_generating_started_at"),
3235 "stage": state.get("stage"),
3236 "goal": goal,
3237 "final_output_path": state.get("final_output_path"),
3238 "final_output_url": state.get("final_output_url"),
3239 "final_video": final_video
3240 or (
3241 shot_rows[0].get("video")
3242 if len(shot_rows) == 1 and isinstance(shot_rows[0].get("video"), dict)
3243 else None
3244 ),
3245 "reference_image": state.get("reference_image"),
3246 "reference_image_locked": is_reference_image_locked(state),
3247 "auto_generate": bool(state.get("auto_generate"))
3248 or self._session_auto_generate_flag(session_key),
3249 "generation_error": state.get("generation_error") or None,
3250 "memory_bank": self._project_memory_bank(paths),
3251 "memory_workspace_assets": self._project_memory_workspace_assets(paths),
3252 "shots": sorted(shot_rows, key=lambda item: int(item["shot_id"])),
3253 "updated_at": payload["updated_at"] or state.get("updated_at"),
3254 **self._echo_tracking_payload(state),
3255 }
3256 )
3257 payload["progress"] = self._workplace_progress(state, payload)
3258 shot_failed = any(str(row.get("status") or "") == "error" for row in shot_rows)
3259 if payload.get("auto_generate") and shot_failed:
3260 payload["stage"] = "failed"
3261 elif (
3262 payload.get("auto_generate")
3263 and payload.get("progress") == "04"
3264 and str(state.get("stage") or "") not in {"done", "failed"}
3265 ):
3266 # Frontend WorkplacePanel still keys content off `stage`, not `progress`.
3267 # Present merging so 04 shows a loading ComposePanel instead of 03 FramesPanel.
3268 payload["stage"] = "merging"
3269 return payload
3270
3271 def _session_auto_generate_flag(self, session_key: str) -> bool:
3272 if self._session_manager is None:
3273 return False
3274 session = self._session_manager.get_or_create(session_key)
3275 metadata = session.metadata if isinstance(session.metadata, dict) else {}
3276 return get_auto_generate(metadata)
3277
3278 def _attach_session_reference_and_auto(
3279 self,
3280 session_key: str,
3281 payload: dict[str, Any],
3282 ) -> dict[str, Any]:
3283 if self._session_manager is None:
3284 payload.setdefault("reference_image", None)
3285 payload.setdefault("reference_image_locked", False)
3286 payload.setdefault("auto_generate", False)
3287 payload.setdefault("progress", "01")
3288 return payload
3289 session = self._session_manager.get_or_create(session_key)
3290 metadata = session.metadata if isinstance(session.metadata, dict) else {}
3291 payload["reference_image"] = normalize_reference_image(metadata.get("reference_image"))
3292 payload["reference_image_locked"] = is_reference_image_locked(metadata)
3293 payload["auto_generate"] = get_auto_generate(metadata)
3294 payload["progress"] = "01"
3295 return payload
3296
3297 @staticmethod
3298 def _workplace_progress(state: dict[str, Any], payload: dict[str, Any]) -> str:
3299 if payload.get("final_output_url") or (
3300 payload.get("final_video") and str(state.get("stage") or "") == "done"
3301 ):
3302 return "done"
3303 if state.get("final_output_url") or state.get("final_output_path"):
3304 return "done"
3305 auto = bool(state.get("auto_generate")) or bool(payload.get("auto_generate"))
3306 stage = str(state.get("stage") or "")
3307 if stage == "done":
3308 return "done"
3309 # 一键成片全程停在 04,避免 regenerate 落到 02/03 闪成逐镜打磨。
3310 if auto:
3311 return "04"
3312 if stage == "merging":
3313 return "04"
3314 if not state.get("story_confirmed"):
3315 return "01"
3316 if stage in {
3317 "shot_generating",
3318 "shot_reviewing",
3319 "shot_revising",
3320 "awaiting_memory_review",
3321 "failed",
3322 }:
3323 return "03"
3324 # 02 only after the user clicks 「下一步」(confirm_story → shot_planning).
3325 # Locking shot_count in chat must stay on 01.
3326 if stage == "shot_planning":
3327 return "02"
3328 return "01"
3329
3330 def _workplace_first_frame_url(self, work_id: str) -> str | None:
3331 state = self._load_workplace_state(work_id)
3332 ref = normalize_reference_image(state.get("reference_image"))
3333 if not ref:
3334 return None
3335 url = ref.get("url")
3336 return url.strip() if isinstance(url, str) and url.strip() else None
3337
3338 def _load_workplace_shot(self, session_key: str, shot_id: int) -> tuple[str, Path, dict[str, Any], dict[str, Any]] | None:
3339 work_id = self._resolve_work_id_for_session(session_key)
3340 if not work_id:
3341 return None
3342 paths = self._workplace_paths(work_id)
3343 if paths is None:
3344 return None
3345 state = self._read_json_file(paths["state"], {})
3346 if not isinstance(state, dict):
3347 state = {}
3348 shot_path = paths["shots"] / f"shot_{shot_id:03d}.json"
3349 shot = self._read_json_file(shot_path, {})
3350 if not isinstance(shot, dict) or not shot:
3351 return None
3352 return work_id, shot_path, state, shot
3353
3354 @staticmethod
3355 def _planned_reference_shot_ids(shot: dict[str, Any]) -> list[int]:
3356 raw = shot.get("planned_reference_shot_ids")
3357 if isinstance(raw, list):
3358 out: list[int] = []
3359 for item in raw:
3360 try:
3361 out.append(int(item))
3362 except (TypeError, ValueError):
3363 continue
3364 return sorted(set(out))
3365 return []
3366
3367 @staticmethod
3368 def _shot_has_generated_media(shot: dict[str, Any]) -> bool:
3369 status = str(shot.get("status") or "")
3370 if status in {"generated", "review_pass", "approved"}:
3371 return True
3372 for key in ("artifact_url", "artifact_path"):
3373 value = shot.get(key)
3374 if isinstance(value, str) and value.strip():
3375 return True
3376 return False
3377
3378 @staticmethod
3379 def _format_reference_dependency_error(shot_id: int, missing: list[int]) -> str:
3380 refs = "、".join(str(item) for item in sorted(missing))
3381 return f"镜头{shot_id}依赖镜头{refs},请先生成镜头{refs},再来操作"
3382
3383 def _previous_shot_approval_error(self, work_id: str, shot_id: int) -> str | None:
3384 """Require the immediately preceding Shot video and Memory before advancing."""
3385 if shot_id <= 1 or not self._memory_review_workflow_enabled():
3386 return None
3387 previous_rows = [
3388 (candidate_id, candidate)
3389 for candidate_id, candidate in self._iter_workplace_shots(work_id)
3390 if candidate_id < shot_id
3391 ]
3392 previous_id = shot_id - 1
3393 previous: dict[str, Any] | None = None
3394 if previous_rows:
3395 previous_id, previous = max(previous_rows, key=lambda item: item[0])
3396 video_accepted = bool(previous) and str(previous.get("status") or "") == "approved"
3397 review = previous.get("memory_review") if isinstance(previous, dict) else None
3398 memory_accepted = isinstance(review, dict) and review.get("status") == "approved"
3399 if video_accepted and memory_accepted:
3400 return None
3401 return f"请先接受镜头{previous_id}并确认其 Memory,再生成镜头{shot_id}"
3402
3403 def _workplace_references_ready(self, work_id: str, *, beat_count: int) -> bool:
3404 paths = self._workplace_paths(work_id)
3405 if paths is None:
3406 return False
3407 shot_paths = sorted(paths["shots"].glob("shot_*.json"))
3408 if not shot_paths:
3409 return False
3410 if beat_count > 0 and len(shot_paths) != beat_count:
3411 return False
3412 for shot_path in shot_paths:
3413 shot = self._read_json_file(shot_path, {})
3414 if not isinstance(shot, dict) or "planned_reference_shot_ids" not in shot:
3415 return False
3416 return True
3417
3418 def _missing_reference_generations(
3419 self,
3420 work_id: str,
3421 reference_shot_ids: list[int],
3422 ) -> list[int]:
3423 paths = self._workplace_paths(work_id)
3424 if paths is None:
3425 return list(reference_shot_ids)
3426 missing: list[int] = []
3427 for ref_id in reference_shot_ids:
3428 shot_path = paths["shots"] / f"shot_{ref_id:03d}.json"
3429 shot = self._read_json_file(shot_path, {})
3430 if not isinstance(shot, dict) or not self._shot_has_generated_media(shot):
3431 missing.append(ref_id)
3432 return missing
3433
3434 def _director_tool_kwargs(self) -> dict[str, Any]:
3435 if self._session_manager is None:
3436 raise RuntimeError("session manager unavailable")
3437 return {
3438 "workspace": self._session_manager.workspace,
3439 "tools_config": self._tools_config,
3440 }
3441
3442 def _ensure_echo_admission(self, *, operation: str = "generate_echo_shot") -> None:
3443 """Reject new generation work when the Echo backend reports overload."""
3444 EchoAdmissionController.from_tools_config(self._tools_config).ensure_allowed(
3445 operation=operation,
3446 )
3447
3448 def _director_generate_tool(self) -> GenerateEchoShotTool:
3449 return GenerateEchoShotTool(**self._director_tool_kwargs())
3450
3451 def _director_merge_tool(self) -> MergeShotTool:
3452 return MergeShotTool(**self._director_tool_kwargs())
3453
3454 def _director_set_references_tool(self) -> SetShotReferencesTool:
3455 return SetShotReferencesTool(**self._director_tool_kwargs())
3456
3457 # ── tail-frame extraction pipeline ──────────────────────────────────────
3458
3459 @staticmethod
3460 def _download_shot_video(video_url: str, target: Path) -> None:
3461 """Download a shot video from *video_url* to a local *target* path."""
3462 target.parent.mkdir(parents=True, exist_ok=True)
3463 source = Path(video_url)
3464 if source.is_file():
3465 shutil.copyfile(source, target)
3466 return
3467 req = urllib.request.Request(
3468 video_url,
3469 headers={"Accept": "video/mp4,*/*", "User-Agent": "EchoDirector/1.0"},
3470 )
3471 with urllib.request.urlopen(req, timeout=120) as response:
3472 with target.open("wb") as output:
3473 shutil.copyfileobj(response, output)
3474 if not target.is_file() or target.stat().st_size <= 0:
3475 raise RuntimeError(f"downloaded empty video: {video_url}")
3476
3477 @staticmethod
3478 def _extract_tail_frame(video_path: Path, output_path: Path) -> bool:
3479 """Extract the last frame of *video_path* as a PNG using ffmpeg.
3480
3481 Returns ``True`` when the output file was created successfully.
3482 """
3483 ffmpeg = _resolve_media_binary("ffmpeg")
3484 cmd = [
3485 ffmpeg,
3486 "-sseof", "-1",
3487 "-i", str(video_path),
3488 "-update", "1",
3489 "-q:v", "1",
3490 str(output_path),
3491 "-y",
3492 ]
3493 try:
3494 result = subprocess.run(cmd, capture_output=True, timeout=60)
3495 except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
3496 logger.error("ffmpeg tail-frame extraction failed: {}", exc)
3497 return False
3498 return result.returncode == 0 and output_path.is_file()
3499
3500 def _publish_tail_frame(
3501 self, image_path: Path, work_id: str, shot_id: int
3502 ) -> str:
3503 """Persist a tail-frame PNG and return its local asset URL."""
3504 publisher = configured_file_publisher(
3505 work_id,
3506 storage=self._tools_config.file_storage,
3507 workspace=self._session_manager.workspace,
3508 )
3509 name = f"tail_frames/shot_{shot_id:03d}.png"
3510 return publisher(str(image_path), name)
3511
3512 def _extract_and_publish_tail_frame(
3513 self, work_id: str, shot_id: int, video_url: str
3514 ) -> str | None:
3515 """Complete tail-frame pipeline: download → ffmpeg → file storage → URL.
3516
3517 Returns the local asset URL of the extracted tail frame, or ``None`` on failure.
3518 """
3519 tmp_dir = Path(tempfile.mkdtemp(prefix="tail_frame_"))
3520 try:
3521 video_path = tmp_dir / "input.mp4"
3522 self._download_shot_video(video_url, video_path)
3523
3524 frame_path = tmp_dir / "tail.png"
3525 if not self._extract_tail_frame(video_path, frame_path):
3526 logger.error(
3527 "ffmpeg failed to extract tail frame for shot {} in work {}",
3528 shot_id, work_id,
3529 )
3530 return None
3531
3532 asset_url = self._publish_tail_frame(frame_path, work_id, shot_id)
3533 logger.info(
3534 "tail frame uploaded shot_id={} work_id={} url={}",
3535 shot_id, work_id, asset_url,
3536 )
3537 return asset_url
3538 except Exception:
3539 logger.opt(exception=True).error(
3540 "tail-frame pipeline failed shot_id={} work_id={}",
3541 shot_id, work_id,
3542 )
3543 return None
3544 finally:
3545 shutil.rmtree(tmp_dir, ignore_errors=True)
3546
3547 # ── reference helpers ───────────────────────────────────────────────────
3548
3549 @staticmethod
3550 def _default_planned_reference_shot_ids(shot_id: int, *, cut: bool) -> list[int]:
3551 if shot_id <= 1:
3552 return []
3553 if not cut:
3554 return [shot_id - 1]
3555 return [shot_id - 1]
3556
3557 def _ensure_workplace_shot_references(self, work_id: str) -> int:
3558 """Fill missing reference plans so the UI is not blocked waiting for the agent."""
3559 paths = self._workplace_paths(work_id)
3560 if paths is None:
3561 return 0
3562 tool = self._director_set_references_tool()
3563 updated = 0
3564 for shot_path in sorted(paths["shots"].glob("shot_*.json")):
3565 shot = self._read_json_file(shot_path, {})
3566 if not isinstance(shot, dict) or "planned_reference_shot_ids" in shot:
3567 continue
3568 if not self._shot_has_caption(shot):
3569 continue
3570 try:
3571 shot_id = int(shot.get("shot_id"))
3572 except (TypeError, ValueError):
3573 stem = shot_path.stem
3574 if not stem.startswith("shot_"):
3575 continue
3576 try:
3577 shot_id = int(stem.removeprefix("shot_"))
3578 except ValueError:
3579 continue
3580 if shot_id <= 0:
3581 continue
3582 cut = bool(shot.get("cut", True))
3583 refs = self._default_planned_reference_shot_ids(shot_id, cut=cut)
3584 note = (
3585 "系统默认:首镜无参考"
3586 if shot_id <= 1
3587 else "系统默认:连续镜头参考上一镜"
3588 )
3589 tool.apply_set_references(work_id, shot_id, refs, selection_note=note)
3590 updated += 1
3591 return updated
3592
3593 def _apply_workplace_shot_duration(
3594 self,
3595 session_key: str,
3596 shot_id: int,
3597 duration_sec: int,
3598 ) -> tuple[str, dict[str, Any]]:
3599 loaded = self._load_workplace_shot(session_key, shot_id)
3600 if loaded is None:
3601 raise ValueError("shot not found")
3602 work_id, shot_path, state, shot = loaded
3603 clamped = self._clamp_shot_duration_sec(duration_sec)
3604 sync_shot_echo_duration(shot, clamped)
3605 self._save_workplace_shot(shot_path, shot)
3606 self._sync_state_shot_row(state, shot)
3607 self._save_workplace_state(work_id, state)
3608 return work_id, self._build_workplace_payload(session_key)
3609
3610 def _mark_workplace_shot_queued(
3611 self,
3612 work_id: str,
3613 shot_path: Path,
3614 shot: dict[str, Any],
3615 ) -> None:
3616 """Mark a workplace shot as queued before async Echo submission."""
3617 shot["status"] = "queued"
3618 shot.pop("generation_error", None)
3619 self._save_workplace_shot(shot_path, shot)
3620 state = self._load_workplace_state(work_id)
3621 state["stage"] = "shot_generating"
3622 self._save_workplace_state(work_id, state)
3623
3624 def _validate_workplace_shot_generate(
3625 self,
3626 session_key: str,
3627 shot_id: int,
3628 *,
3629 reference_image_url: str | None = None,
3630 reference_image_name: str | None = None,
3631 reference_image_width: int | None = None,
3632 reference_image_height: int | None = None,
3633 ) -> tuple[str, Path, dict[str, Any], dict[str, Any], list[int], str | None, bool]:
3634 """Validate a workplace shot generate request and return submit context."""
3635 self._ensure_echo_admission(operation="generate_echo_shot")
3636 loaded = self._load_workplace_shot(session_key, shot_id)
3637 if loaded is None:
3638 raise ValueError("shot not found")
3639 work_id, shot_path, _state, shot = loaded
3640 use_continuous = (
3641 bool(shot.get("continuous_enabled"))
3642 and shot_id > 1
3643 and not self._workplace_auto_generate_active(session_key)
3644 )
3645 self._lock_workplace_video_size(session_key, work_id)
3646 if reference_image_url:
3647 logger.info(
3648 "workplace shot generate: ignoring request-body reference_image_* "
3649 "session_key={} work_id={} shot_id={} url={}",
3650 session_key,
3651 work_id,
3652 shot_id,
3653 reference_image_url,
3654 )
3655 status = str(shot.get("status") or "")
3656 if status == "queued":
3657 raise ValueError(f"shot {shot_id} generation already in progress")
3658 if status in {"generated", "review_pass", "approved"}:
3659 raise ValueError(f"shot {shot_id} is already generated")
3660 approval_error = self._previous_shot_approval_error(work_id, shot_id)
3661 if approval_error:
3662 raise ValueError(approval_error)
3663 self._ensure_workplace_shot_references(work_id)
3664 if "planned_reference_shot_ids" not in shot:
3665 shot = self._read_json_file(shot_path, {})
3666 if "planned_reference_shot_ids" not in shot:
3667 raise ValueError("reference plan is not ready; complete the previous workflow step first")
3668 reference_shot_ids = self._planned_reference_shot_ids(shot)
3669 missing = self._missing_reference_generations(work_id, reference_shot_ids)
3670 if missing:
3671 raise ValueError(self._format_reference_dependency_error(shot_id, missing))
3672 selection_note = shot.get("reference_selection_note")
3673 note = selection_note if isinstance(selection_note, str) else None
3674 return work_id, shot_path, shot, _state, reference_shot_ids, note, use_continuous
3675
3676 def _submit_workplace_shot_generate(
3677 self,
3678 session_key: str,
3679 work_id: str,
3680 shot_id: int,
3681 shot: dict[str, Any],
3682 *,
3683 reference_shot_ids: list[int],
3684 selection_note: str | None,
3685 use_continuous: bool,
3686 reference_image_url: str | None = None,
3687 i2v_prompt: str | None = None,
3688 ) -> dict[str, Any]:
3689 """Submit a validated workplace shot to Echo."""
3690 generate_tool = self._director_generate_tool()
3691 generate_tool.set_context(
3692 "websocket",
3693 webui_wire_chat_id(session_key) or "direct",
3694 effective_key=session_key,
3695 )
3696 note = selection_note
3697
3698 if use_continuous:
3699 previous_shot_id = shot_id - 1
3700 logger.info(
3701 "generate shot: shot_id={} continuous_enabled=True, "
3702 "using I2V (tail frame from shot {})",
3703 shot_id,
3704 previous_shot_id,
3705 )
3706 prev_loaded = self._load_workplace_shot(session_key, previous_shot_id)
3707 if prev_loaded is None:
3708 raise ValueError(f"previous shot {previous_shot_id} not found")
3709 _prev_work_id, prev_shot_path, _prev_state, prev_shot = prev_loaded
3710 prev_status = str(prev_shot.get("status") or "")
3711 if prev_status not in {"generated", "review_pass", "approved"}:
3712 raise ValueError(
3713 f"previous shot {previous_shot_id} must be generated first "
3714 f"(current: {prev_status})"
3715 )
3716 video_url = prev_shot.get("artifact_url") or (
3717 prev_shot.get("echo") or {}
3718 ).get("result_url")
3719 if not video_url:
3720 raise ValueError(
3721 f"previous shot {previous_shot_id} has no artifact_url; "
3722 f"cannot extract tail frame for continuous generation"
3723 )
3724 logger.info(
3725 "generate shot: shot_id={} extracting tail frame from "
3726 "previous shot {} video_url={}",
3727 shot_id,
3728 previous_shot_id,
3729 video_url,
3730 )
3731 condition_image_url = self._extract_and_publish_tail_frame(
3732 work_id,
3733 previous_shot_id,
3734 video_url,
3735 )
3736 if not condition_image_url:
3737 raise ValueError(
3738 f"failed to extract tail frame from shot {previous_shot_id}"
3739 )
3740 logger.info(
3741 "generate shot: shot_id={} tail frame ready, "
3742 "condition_image_url={}",
3743 shot_id,
3744 condition_image_url,
3745 )
3746 prev_shot["tail_frame_url"] = condition_image_url
3747 self._save_workplace_shot(prev_shot_path, prev_shot)
3748 if previous_shot_id not in reference_shot_ids:
3749 reference_shot_ids = sorted(
3750 set(reference_shot_ids) | {previous_shot_id}
3751 )
3752 job = generate_tool.apply_generate_continuous(
3753 work_id,
3754 shot_id,
3755 condition_image_url,
3756 reference_shot_ids,
3757 selection_note=note,
3758 i2v_prompt=i2v_prompt,
3759 )
3760 elif shot_id == 1 and reference_image_url:
3761 logger.info(
3762 "generate shot: shot_id=1 using user first-frame reference "
3763 "image (R2V with condition_img) url={}",
3764 reference_image_url,
3765 )
3766 job = generate_tool.apply_generate(
3767 work_id,
3768 shot_id,
3769 reference_shot_ids,
3770 selection_note=(
3771 note or "Director R2V with user first-frame reference"
3772 ),
3773 condition_image_url=reference_image_url,
3774 i2v_prompt=i2v_prompt,
3775 )
3776 else:
3777 logger.info(
3778 "generate shot: shot_id={} continuous_enabled={}, "
3779 "using T2V (no tail frame)",
3780 shot_id,
3781 shot.get("continuous_enabled", False),
3782 )
3783 job = generate_tool.apply_generate(
3784 work_id,
3785 shot_id,
3786 reference_shot_ids,
3787 selection_note=note,
3788 )
3789 version_id = (
3790 job.get("remote", {}).get("version_id")
3791 if isinstance(job.get("remote"), dict)
3792 else None
3793 )
3794 logger.info(
3795 "workplace shot generate submitted work_id={} shot_id={} version_id={}",
3796 work_id,
3797 shot_id,
3798 version_id,
3799 )
3800 return job
3801
3802 def _prepare_workplace_shot_generate_async(
3803 self,
3804 session_key: str,
3805 shot_id: int,
3806 *,
3807 reference_image_url: str | None = None,
3808 reference_image_name: str | None = None,
3809 reference_image_width: int | None = None,
3810 reference_image_height: int | None = None,
3811 ) -> tuple[str, dict[str, Any]]:
3812 """Validate shot 1 R2V generation and queue it for async re-caption + submit."""
3813 work_id, shot_path, shot, _state, _reference_shot_ids, _note, _use_continuous = (
3814 self._validate_workplace_shot_generate(
3815 session_key,
3816 shot_id,
3817 reference_image_url=reference_image_url,
3818 reference_image_name=reference_image_name,
3819 reference_image_width=reference_image_width,
3820 reference_image_height=reference_image_height,
3821 )
3822 )
3823 self._mark_workplace_shot_queued(work_id, shot_path, shot)
3824 workplace = self._build_workplace_payload(session_key)
3825 return work_id, workplace
3826
3827 async def _complete_workplace_shot_generate_with_reference(
3828 self,
3829 session_key: str,
3830 *,
3831 work_id: str,
3832 shot_id: int,
3833 reference_image_url: str,
3834 ) -> None:
3835 """Background: PE I2V rewrite → Echo submit → workplace push."""
3836 try:
3837 if self._provider is None or not self._model:
3838 raise RuntimeError("re-caption model unavailable")
3839 generate_tool = self._director_generate_tool()
3840 shot = generate_tool._load_shot(work_id, shot_id)
3841 caption = str(shot.get("caption") or "").strip()
3842 if not caption:
3843 raise ValueError(f"Shot {shot_id} has no caption yet.")
3844 story_profile = self._load_workplace_story_profile(work_id)
3845 rewritten_prompt = await self._rewrite_i2v_prompt_with_image(
3846 caption,
3847 reference_image_url,
3848 story_profile,
3849 is_first_frame=True,
3850 enforce_first_frame_continuity=True,
3851 )
3852 reference_shot_ids = self._planned_reference_shot_ids(shot)
3853 selection_note = shot.get("reference_selection_note")
3854 note = selection_note if isinstance(selection_note, str) else None
3855 await asyncio.to_thread(
3856 self._submit_workplace_shot_generate,
3857 session_key,
3858 work_id,
3859 shot_id,
3860 shot,
3861 reference_shot_ids=reference_shot_ids,
3862 selection_note=note,
3863 use_continuous=False,
3864 reference_image_url=reference_image_url,
3865 i2v_prompt=rewritten_prompt,
3866 )
3867 except Exception as exc:
3868 logger.opt(exception=True).error(
3869 "workplace shot generate async failed work_id={} shot_id={}",
3870 work_id,
3871 shot_id,
3872 )
3873 self._mark_workplace_shot_failed(session_key, work_id, shot_id, str(exc))
3874 try:
3875 await self._publish_workplace_update(session_key)
3876 except Exception:
3877 logger.opt(exception=True).error(
3878 "workplace shot generate update failed after async submit work_id={}",
3879 work_id,
3880 )
3881
3882 def _apply_workplace_shot_generate(
3883 self,
3884 session_key: str,
3885 shot_id: int,
3886 *,
3887 reference_image_url: str | None = None,
3888 reference_image_name: str | None = None,
3889 reference_image_width: int | None = None,
3890 reference_image_height: int | None = None,
3891 ) -> tuple[str, dict[str, Any]]:
3892 work_id, _shot_path, shot, _state, reference_shot_ids, note, use_continuous = (
3893 self._validate_workplace_shot_generate(
3894 session_key,
3895 shot_id,
3896 )
3897 )
3898 if shot_id == 1 and not reference_image_url:
3899 reference_image_url = self._workplace_first_frame_url(work_id)
3900 elif reference_image_url:
3901 logger.info(
3902 "workplace shot generate: ignoring request-body reference_image "
3903 "session_key={} shot_id={}",
3904 session_key,
3905 shot_id,
3906 )
3907 reference_image_url = (
3908 self._workplace_first_frame_url(work_id) if shot_id == 1 else None
3909 )
3910 self._submit_workplace_shot_generate(
3911 session_key,
3912 work_id,
3913 shot_id,
3914 shot,
3915 reference_shot_ids=reference_shot_ids,
3916 selection_note=note,
3917 use_continuous=use_continuous,
3918 reference_image_url=reference_image_url,
3919 )
3920 return work_id, self._build_workplace_payload(session_key)
3921
3922 def _iter_workplace_shots(self, work_id: str) -> list[tuple[int, dict[str, Any]]]:
3923 paths = self._workplace_paths(work_id)
3924 if paths is None:
3925 return []
3926 rows: list[tuple[int, dict[str, Any]]] = []
3927 for shot_path in sorted(paths["shots"].glob("shot_*.json")):
3928 shot = self._read_json_file(shot_path, {})
3929 if not isinstance(shot, dict):
3930 continue
3931 try:
3932 shot_id = int(shot.get("shot_id"))
3933 except (TypeError, ValueError):
3934 continue
3935 rows.append((shot_id, shot))
3936 return rows
3937
3938 def _lock_workplace_video_size(self, session_key: str, work_id: str) -> tuple[int, int]:
3939 """Copy the session size into the Director work once, before first generation."""
3940 state = self._load_workplace_state(work_id)
3941 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
3942 try:
3943 locked_width = int(goal.get("width"))
3944 locked_height = int(goal.get("height"))
3945 except (TypeError, ValueError):
3946 locked_width = locked_height = 0
3947 if locked_width > 0 and locked_height > 0:
3948 return locked_width, locked_height
3949
3950 from nanobot.session.generation_settings import get_generation_settings
3951
3952 metadata: dict[str, Any] = {}
3953 if self._session_manager is not None:
3954 metadata = self._session_manager.get_or_create(session_key).metadata
3955 settings = get_generation_settings(metadata)
3956 width = int(settings["width"])
3957 height = int(settings["height"])
3958 goal["width"] = width
3959 goal["height"] = height
3960 state["goal"] = goal
3961 self._save_workplace_state(work_id, state)
3962 return width, height
3963
3964 def _iter_workplace_shots_for_workflow(
3965 self,
3966 work_id: str,
3967 *,
3968 beat_count: int = 0,
3969 ) -> list[tuple[int, dict[str, Any]]]:
3970 rows = self._iter_workplace_shots(work_id)
3971 if beat_count > 0:
3972 rows = [(shot_id, shot) for shot_id, shot in rows if shot_id <= beat_count]
3973 return rows
3974
3975 def _raise_generate_all_unavailable(
3976 self,
3977 work_id: str,
3978 *,
3979 beat_count: int = 0,
3980 ) -> None:
3981 rows = self._iter_workplace_shots_for_workflow(work_id, beat_count=beat_count)
3982 statuses = [str(shot.get("status") or "") for _shot_id, shot in rows]
3983 detail = ", ".join(f"{shot_id}:{status}" for shot_id, status in zip(
3984 (shot_id for shot_id, _ in rows),
3985 statuses,
3986 strict=True,
3987 ))
3988 suffix = f" (work_id={work_id}, shots=[{detail}])" if detail else f" (work_id={work_id})"
3989 if statuses and all(
3990 status in {"generated", "review_pass", "approved"} for status in statuses
3991 ):
3992 raise ValueError(f"all shots are already generated{suffix}")
3993 if statuses and all(
3994 status in {"queued", "generated", "review_pass", "approved"} for status in statuses
3995 ):
3996 raise ValueError(f"shots are already generating{suffix}")
3997 raise ValueError(f"no shots are ready to generate{suffix}")
3998
3999 def _submit_workplace_echo_generation(
4000 self,
4001 session_key: str,
4002 work_id: str,
4003 shot_id: int,
4004 reference_shot_ids: list[int],
4005 selection_note: str | None,
4006 ) -> None:
4007 generate_tool = self._director_generate_tool()
4008 generate_tool.set_context(
4009 "websocket",
4010 webui_wire_chat_id(session_key) or "direct",
4011 effective_key=session_key,
4012 )
4013 self._clear_auto_generate_memory_wait(work_id)
4014
4015 # Check if continuous (I2V) mode is enabled for this shot.
4016 loaded = self._load_workplace_shot(session_key, shot_id)
4017 shot = loaded[3] if loaded is not None else {}
4018 use_continuous = (
4019 bool(shot.get("continuous_enabled"))
4020 and shot_id > 1
4021 and not self._workplace_auto_generate_active(session_key)
4022 )
4023
4024 if use_continuous:
4025 # ── I2V path: extract previous shot's tail frame ──
4026 previous_shot_id = shot_id - 1
4027 logger.info(
4028 "submit_echo_generation: shot_id={} continuous_enabled=True, "
4029 "using I2V (tail frame from shot {})",
4030 shot_id, previous_shot_id,
4031 )
4032 prev_loaded = self._load_workplace_shot(session_key, previous_shot_id)
4033 if prev_loaded is None:
4034 raise ValueError(f"previous shot {previous_shot_id} not found")
4035 _prev_work_id, prev_shot_path, _prev_state, prev_shot = prev_loaded
4036 prev_status = str(prev_shot.get("status") or "")
4037 if prev_status not in {"generated", "review_pass", "approved"}:
4038 raise ValueError(
4039 f"previous shot {previous_shot_id} must be generated first "
4040 f"(current: {prev_status})"
4041 )
4042 video_url = prev_shot.get("artifact_url") or (
4043 prev_shot.get("echo") or {}
4044 ).get("result_url")
4045 if not video_url:
4046 raise ValueError(
4047 f"previous shot {previous_shot_id} has no artifact_url; "
4048 f"cannot extract tail frame for continuous generation"
4049 )
4050 logger.info(
4051 "submit_echo_generation: shot_id={} extracting tail frame from "
4052 "previous shot {} video_url={}",
4053 shot_id, previous_shot_id, video_url,
4054 )
4055 condition_image_url = self._extract_and_publish_tail_frame(
4056 work_id, previous_shot_id, video_url,
4057 )
4058 if not condition_image_url:
4059 raise ValueError(
4060 f"failed to extract tail frame from shot {previous_shot_id}"
4061 )
4062 logger.info(
4063 "submit_echo_generation: shot_id={} tail frame ready, "
4064 "condition_image_url={}",
4065 shot_id, condition_image_url,
4066 )
4067 # Persist tail_frame_url on previous shot for caching and UI display.
4068 prev_shot["tail_frame_url"] = condition_image_url
4069 self._save_workplace_shot(prev_shot_path, prev_shot)
4070 # Ensure the previous shot is included as a reference.
4071 if previous_shot_id not in reference_shot_ids:
4072 reference_shot_ids = sorted(
4073 set(reference_shot_ids) | {previous_shot_id}
4074 )
4075 try:
4076 generate_tool.apply_generate_continuous(
4077 work_id,
4078 shot_id,
4079 condition_image_url,
4080 reference_shot_ids,
4081 selection_note=selection_note,
4082 )
4083 except EchoGeneratorUnavailableError as exc:
4084 self._report_echo_unavailable(
4085 session_key, exc, work_id=work_id, shot_id=shot_id
4086 )
4087 raise
4088 else:
4089 first_frame_url = self._workplace_first_frame_url(work_id) if shot_id == 1 else None
4090 if first_frame_url:
4091 logger.info(
4092 "submit_echo_generation: shot_id=1 using state.reference_image "
4093 "url={}",
4094 first_frame_url,
4095 )
4096 try:
4097 loop = asyncio.get_running_loop()
4098 if loaded is not None:
4099 _work, shot_path, _state, shot_obj = loaded
4100 self._mark_workplace_shot_queued(work_id, shot_path, shot_obj)
4101 loop.create_task(
4102 self._complete_workplace_shot_generate_with_reference(
4103 session_key,
4104 work_id=work_id,
4105 shot_id=shot_id,
4106 reference_image_url=first_frame_url,
4107 )
4108 )
4109 return
4110 except RuntimeError:
4111 caption = str(shot.get("caption") or "").strip()
4112 profile = self._load_workplace_story_profile(work_id)
4113 language = ""
4114 if isinstance(profile, dict):
4115 language = str(
4116 profile.get("caption_language") or profile.get("language") or ""
4117 )
4118 i2v_prompt = (
4119 rewrite_prompt_for_i2v(caption, language) if caption else None
4120 )
4121 try:
4122 generate_tool.apply_generate(
4123 work_id,
4124 shot_id,
4125 reference_shot_ids,
4126 selection_note=selection_note,
4127 condition_image_url=first_frame_url,
4128 i2v_prompt=i2v_prompt,
4129 )
4130 except EchoGeneratorUnavailableError as exc:
4131 self._report_echo_unavailable(
4132 session_key, exc, work_id=work_id, shot_id=shot_id
4133 )
4134 raise
4135 return
4136 logger.info(
4137 "submit_echo_generation: shot_id={} continuous_enabled={}, "
4138 "using T2V (no tail frame)",
4139 shot_id, shot.get("continuous_enabled", False),
4140 )
4141 try:
4142 generate_tool.apply_generate(
4143 work_id,
4144 shot_id,
4145 reference_shot_ids,
4146 selection_note=selection_note,
4147 )
4148 except EchoGeneratorUnavailableError as exc:
4149 self._report_echo_unavailable(
4150 session_key, exc, work_id=work_id, shot_id=shot_id
4151 )
4152 raise
4153
4154 def _apply_workplace_generate_all(
4155 self,
4156 session_key: str,
4157 ) -> tuple[str, dict[str, Any], list[int]]:
4158 self._ensure_echo_admission(operation="generate_echo_shot")
4159 work_id = self._resolve_work_id_for_session(session_key)
4160 if not work_id:
4161 raise ValueError("director work not found")
4162 paths = self._workplace_paths(work_id)
4163 if paths is None:
4164 raise ValueError("director work not found")
4165 self._lock_workplace_video_size(session_key, work_id)
4166 profile = self._load_workplace_story_profile(work_id)
4167 beats = profile.get("beats") if isinstance(profile.get("beats"), list) else []
4168 beat_count = len(beats)
4169 state = self._load_workplace_state(work_id)
4170 if beat_count > 0:
4171 self._prune_workplace_shots_beyond_count(work_id, beat_count, state)
4172 self._reconcile_workplace_state_shots(work_id, state)
4173 self._ensure_workplace_shot_references(work_id)
4174 if not self._workplace_references_ready(work_id, beat_count=beat_count):
4175 raise ValueError("reference plan is not ready; complete the previous workflow step first")
4176
4177 submitted: list[int] = []
4178 approval_errors: list[str] = []
4179 for shot_id, shot in self._iter_workplace_shots_for_workflow(work_id, beat_count=beat_count):
4180 status = str(shot.get("status") or "")
4181 if status in {"queued", "generated", "review_pass", "approved"}:
4182 continue
4183 if "planned_reference_shot_ids" not in shot:
4184 continue
4185 approval_error = self._previous_shot_approval_error(work_id, shot_id)
4186 if approval_error:
4187 approval_errors.append(approval_error)
4188 continue
4189 reference_shot_ids = self._planned_reference_shot_ids(shot)
4190 selection_note = shot.get("reference_selection_note")
4191 note = selection_note if isinstance(selection_note, str) else None
4192 self._submit_workplace_echo_generation(
4193 session_key,
4194 work_id,
4195 shot_id,
4196 reference_shot_ids,
4197 note,
4198 )
4199 submitted.append(shot_id)
4200 if self._memory_review_workflow_enabled():
4201 break
4202
4203 if not submitted and approval_errors:
4204 raise ValueError(approval_errors[0])
4205 if not submitted:
4206 self._raise_generate_all_unavailable(work_id, beat_count=beat_count)
4207 logger.info(
4208 "workplace generate-all submitted work_id={} shot_ids={}",
4209 work_id,
4210 submitted,
4211 )
4212 return work_id, self._build_workplace_payload(session_key), submitted
4213
4214 def _load_workplace_story_profile(self, work_id: str) -> dict[str, Any]:
4215 paths = self._workplace_paths(work_id)
4216 if paths is None:
4217 return {}
4218 data = self._read_json_file(paths["story_profile"], {})
4219 return data if isinstance(data, dict) else {}
4220
4221 def _sync_story_profile_language(self, session_key: str, language: str) -> None:
4222 """Write UI language into story_profile when a director work already exists."""
4223 from nanobot.session.generation_settings import (
4224 language_to_caption_language,
4225 language_to_dialogue_language,
4226 normalize_language,
4227 )
4228
4229 normalized = normalize_language(language)
4230 if normalized is None:
4231 return
4232 work_id = self._resolve_work_id_for_session(session_key)
4233 if not work_id:
4234 return
4235 paths = self._workplace_paths(work_id)
4236 if paths is None:
4237 return
4238 profile = self._load_workplace_story_profile(work_id)
4239 profile["language"] = normalized
4240 dialogue = language_to_dialogue_language(normalized)
4241 if dialogue:
4242 profile["dialogue_language"] = dialogue
4243 caption = language_to_caption_language(normalized)
4244 if caption:
4245 profile["caption_language"] = caption
4246 paths["story_profile"].parent.mkdir(parents=True, exist_ok=True)
4247 paths["story_profile"].write_text(
4248 json.dumps(profile, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
4249 encoding="utf-8",
4250 )
4251 state = self._load_workplace_state(work_id)
4252 if isinstance(state, dict):
4253 state["story_profile"] = profile
4254 self._save_workplace_state(work_id, state)
4255 try:
4256 loop = asyncio.get_running_loop()
4257 loop.create_task(self._publish_workplace_update(session_key))
4258 except RuntimeError:
4259 pass
4260
4261 @staticmethod
4262 def _normalize_story_profile_beats(beats: list[Any]) -> list[dict[str, Any]]:
4263 normalized: list[dict[str, Any]] = []
4264 for index, beat in enumerate(beats):
4265 if not isinstance(beat, dict):
4266 raise ValueError("each beat must be an object with shot_id and summary")
4267 summary = str(beat.get("summary") or "").strip()
4268 if not summary:
4269 raise ValueError("each beat.summary must be a non-empty string")
4270 normalized.append({"shot_id": index + 1, "summary": summary})
4271 return normalized
4272
4273 @staticmethod
4274 def _validate_story_profile(profile: dict[str, Any]) -> None:
4275 summary = profile.get("summary")
4276 if not isinstance(summary, str) or not summary.strip():
4277 raise ValueError("story_profile.summary must be a non-empty string")
4278 beats = profile.get("beats")
4279 if not isinstance(beats, list) or len(beats) < 1:
4280 raise ValueError("story_profile.beats must contain at least one beat")
4281 profile["beats"] = WebSocketChannel._normalize_story_profile_beats(beats)
4282
4283 @staticmethod
4284 def _sync_story_profile_derivatives(profile: dict[str, Any]) -> None:
4285 beats = profile.get("beats")
4286 if not isinstance(beats, list):
4287 return
4288 shot_to_content: dict[str, str] = {}
4289 content_to_shots: dict[str, list[str]] = {}
4290 for index, beat in enumerate(beats):
4291 if not isinstance(beat, dict):
4292 continue
4293 shot_id = beat.get("shot_id") or (index + 1)
4294 try:
4295 shot_id = int(shot_id)
4296 except (TypeError, ValueError):
4297 shot_id = index + 1
4298 shot_key = f"shot_{shot_id:03d}"
4299 summary = str(beat.get("summary") or "").strip()
4300 shot_to_content[shot_key] = summary
4301 content_to_shots[f"beat_{shot_id:03d}"] = [shot_key]
4302 profile["shot_to_content"] = shot_to_content
4303 profile["content_to_shots"] = content_to_shots
4304
4305 def _save_workplace_story_profile(
4306 self,
4307 work_id: str,
4308 profile: dict[str, Any],
4309 state: dict[str, Any],
4310 ) -> None:
4311 from nanobot.session.generation_settings import (
4312 language_to_caption_language,
4313 language_to_dialogue_language,
4314 normalize_language,
4315 )
4316
4317 paths = self._workplace_paths(work_id)
4318 if paths is None:
4319 return
4320 previous = self._load_workplace_story_profile(work_id)
4321 language = normalize_language(profile.get("language"))
4322 if language is None:
4323 language = normalize_language(previous.get("language"))
4324 if language is not None:
4325 profile["language"] = language
4326 dialogue = language_to_dialogue_language(language)
4327 if dialogue and not (
4328 isinstance(profile.get("dialogue_language"), str)
4329 and str(profile.get("dialogue_language") or "").strip()
4330 ):
4331 profile["dialogue_language"] = dialogue
4332 caption = language_to_caption_language(language)
4333 if caption and not str(profile.get("caption_language") or "").strip():
4334 profile["caption_language"] = caption
4335 elif (
4336 isinstance(previous.get("dialogue_language"), str)
4337 and previous["dialogue_language"].strip()
4338 and not (
4339 isinstance(profile.get("dialogue_language"), str)
4340 and str(profile.get("dialogue_language") or "").strip()
4341 )
4342 ):
4343 profile["dialogue_language"] = previous["dialogue_language"].strip()
4344 if not str(profile.get("caption_language") or "").strip():
4345 previous_caption = str(previous.get("caption_language") or "").strip()
4346 if previous_caption:
4347 profile["caption_language"] = previous_caption
4348 else:
4349 fallback_language = normalize_language(profile.get("dialogue_language"))
4350 caption = language_to_caption_language(fallback_language)
4351 if caption:
4352 profile["caption_language"] = caption
4353 self._validate_story_profile(profile)
4354 self._sync_story_profile_derivatives(profile)
4355 paths["story_profile"].parent.mkdir(parents=True, exist_ok=True)
4356 paths["story_profile"].write_text(
4357 json.dumps(profile, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
4358 encoding="utf-8",
4359 )
4360 state["story_profile"] = profile
4361 goal = state.setdefault("goal", {})
4362 if isinstance(goal, dict):
4363 beats = profile.get("beats")
4364 # Only sync shot_count from beats after the user has locked it.
4365 # Provisional beats during 01 must not jump the workplace to 02.
4366 if isinstance(beats, list) and locked_shot_count_from_goal(goal):
4367 goal["shot_count"] = len(beats)
4368
4369 def _workplace_has_generated_video(self, work_id: str, state: dict[str, Any]) -> bool:
4370 if state.get("final_output_url") or state.get("final_output_path"):
4371 return True
4372 paths = self._workplace_paths(work_id)
4373 if paths is None:
4374 return False
4375 for shot_path in paths["shots"].glob("shot_*.json"):
4376 shot = self._read_json_file(shot_path, {})
4377 if isinstance(shot, dict) and self._shot_has_generated_media(shot):
4378 return True
4379 return False
4380
4381 def _clear_workplace_shot_files(self, work_id: str, state: dict[str, Any]) -> None:
4382 paths = self._workplace_paths(work_id)
4383 if paths is None:
4384 return
4385 for shot_path in paths["shots"].glob("shot_*.json"):
4386 shot_path.unlink(missing_ok=True)
4387 state["shots"] = {}
4388
4389 def _prune_workplace_shots_beyond_count(
4390 self,
4391 work_id: str,
4392 beat_count: int,
4393 state: dict[str, Any],
4394 ) -> None:
4395 """Remove orphan shot files/state rows when beats were merged or split."""
4396 if beat_count <= 0:
4397 return
4398 paths = self._workplace_paths(work_id)
4399 if paths is None:
4400 return
4401 changed = False
4402 for shot_path in list(paths["shots"].glob("shot_*.json")):
4403 shot = self._read_json_file(shot_path, {})
4404 shot_id = 0
4405 if isinstance(shot, dict):
4406 try:
4407 shot_id = int(shot.get("shot_id") or 0)
4408 except (TypeError, ValueError):
4409 shot_id = 0
4410 if shot_id <= 0:
4411 stem = shot_path.stem
4412 if stem.startswith("shot_"):
4413 try:
4414 shot_id = int(stem.removeprefix("shot_"))
4415 except ValueError:
4416 shot_id = 0
4417 if shot_id > beat_count:
4418 shot_path.unlink(missing_ok=True)
4419 changed = True
4420 shots = state.get("shots")
4421 if isinstance(shots, dict):
4422 for key in list(shots.keys()):
4423 entry = shots[key]
4424 if not isinstance(entry, dict):
4425 continue
4426 try:
4427 shot_id = int(entry.get("shot_id") or 0)
4428 except (TypeError, ValueError):
4429 continue
4430 if shot_id > beat_count:
4431 del shots[key]
4432 changed = True
4433 if changed:
4434 self._save_workplace_state(work_id, state)
4435
4436 def _load_workplace_state(self, work_id: str) -> dict[str, Any]:
4437 paths = self._workplace_paths(work_id)
4438 if paths is None:
4439 return {}
4440 state = self._read_json_file(paths["state"], {})
4441 return state if isinstance(state, dict) else {}
4442
4443 _BEATS_EDITABLE_STAGES = frozenset(
4444 {"story_discussion", "story_confirmed", "shot_planning"},
4445 )
4446 _REGENERATION_SHOT_CLEAR_KEYS = (
4447 "artifact_url",
4448 "artifact_path",
4449 "generation_error",
4450 "last_job_id",
4451 "echo",
4452 "remote_result",
4453 "last_review",
4454 "review_notes",
4455 "reference_shot_ids",
4456 "reference_selection_note",
4457 "planned_reference_shot_ids",
4458 "memory_review",
4459 )
4460
4461 def _workplace_beats_editable(self, work_id: str, state: dict[str, Any]) -> bool:
4462 stage = str(state.get("stage") or "")
4463 if stage not in self._BEATS_EDITABLE_STAGES:
4464 return False
4465 return not self._workplace_has_generated_video(work_id, state)
4466
4467 def _assert_story_discussion_editable(self, state: dict[str, Any], *, field: str) -> None:
4468 if str(state.get("stage") or "") != "story_discussion":
4469 raise ValueError(f"{field} can only be edited during story_discussion")
4470
4471 def _assert_beats_editable(self, work_id: str, state: dict[str, Any]) -> None:
4472 if not self._workplace_beats_editable(work_id, state):
4473 raise ValueError(
4474 "story_profile.beats can only be edited before video generation "
4475 "during story_discussion, story_confirmed, or shot_planning"
4476 )
4477
4478 def _find_beat_index(self, beats: list[dict[str, Any]], shot_id: int) -> int:
4479 for index, beat in enumerate(beats):
4480 try:
4481 if int(beat.get("shot_id") or 0) == shot_id:
4482 return index
4483 except (TypeError, ValueError):
4484 continue
4485 raise ValueError(f"beat {shot_id} not found")
4486
4487 def _save_workplace_state(self, work_id: str, state: dict[str, Any]) -> None:
4488 paths = self._workplace_paths(work_id)
4489 if paths is None:
4490 return
4491 state["updated_at"] = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
4492 self._write_json_file(paths["state"], state)
4493
4494 def _try_sync_workplace_story_confirmed(
4495 self,
4496 work_id: str,
4497 paths: dict[str, Path],
4498 state: dict[str, Any],
4499 ) -> bool:
4500 """Persist story_confirmed when screenplay and profile already validate."""
4501 if state.get("story_confirmed"):
4502 return True
4503 if not self._workplace_story_text(paths).strip():
4504 return False
4505 profile = self._load_workplace_story_profile(work_id)
4506 if _story_profile_validation_error(profile):
4507 return False
4508 state["story_confirmed"] = True
4509 self._save_workplace_state(work_id, state)
4510 return True
4511
4512 def _save_workplace_shot(self, shot_path: Path, shot: dict[str, Any]) -> None:
4513 shot["updated_at"] = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
4514 self._write_json_file(shot_path, shot)
4515
4516 @staticmethod
4517 def _shot_has_caption(shot: dict[str, Any]) -> bool:
4518 caption = shot.get("caption")
4519 return isinstance(caption, str) and bool(caption.strip())
4520
4521 @classmethod
4522 def _reset_shot_for_regeneration(cls, shot: dict[str, Any]) -> None:
4523 for key in cls._REGENERATION_SHOT_CLEAR_KEYS:
4524 shot.pop(key, None)
4525 if cls._shot_has_caption(shot):
4526 shot["status"] = "prompt_ready"
4527 else:
4528 shot["status"] = "planned"
4529
4530 def _apply_workplace_regenerate(
4531 self,
4532 session_key: str,
4533 ) -> tuple[str, dict[str, Any]]:
4534 """Return to storyboard editing after a final video so the user can revise and re-run."""
4535 work_id = self._resolve_work_id_for_session(session_key)
4536 if not work_id:
4537 raise ValueError("director work not found")
4538 paths = self._workplace_paths(work_id)
4539 if paths is None:
4540 raise ValueError("director work not found")
4541 state = self._read_json_file(paths["state"], {})
4542 if not isinstance(state, dict):
4543 state = {}
4544 stage = str(state.get("stage") or "")
4545 has_final_output = bool(state.get("final_output_url") or state.get("final_output_path"))
4546 if stage not in {"done", "merging"} and not has_final_output:
4547 raise ValueError("regenerate is only available after a final video was produced")
4548
4549 state["pending_remote_jobs"] = {}
4550 state.pop("final_output_url", None)
4551 state.pop("final_output_path", None)
4552 state.pop("latest_merge_job_id", None)
4553 state.pop("review_completed_at", None)
4554 for key in (
4555 "echo_request_id",
4556 "like_status",
4557 "prompt_downloaded",
4558 "video_downloaded",
4559 ):
4560 state.pop(key, None)
4561 state["sequential_generate_all"] = False
4562 state["stage"] = "shot_planning"
4563
4564 for shot_path in sorted(paths["shots"].glob("shot_*.json")):
4565 shot = self._read_json_file(shot_path, {})
4566 if not isinstance(shot, dict):
4567 continue
4568 self._reset_shot_for_regeneration(shot)
4569 self._save_workplace_shot(shot_path, shot)
4570 self._sync_state_shot_row(state, shot)
4571
4572 self._save_workplace_state(work_id, state)
4573 # Shot specs survive regenerate but reference plans are cleared; restore
4574 # defaults immediately so step-3 generate-all is not blocked.
4575 self._ensure_workplace_shot_references(work_id)
4576 auto = bool(state.get("auto_generate")) or self._session_auto_generate_flag(
4577 session_key
4578 )
4579 if auto:
4580 state = self._load_workplace_state(work_id)
4581 state["auto_generate"] = True
4582 state["auto_generate_retry_count"] = 0
4583 state.pop("auto_generate_waited_memory", None)
4584 self._save_workplace_state(work_id, state)
4585 self._continue_auto_generate(session_key)
4586 return work_id, self._build_workplace_payload(session_key)
4587
4588 @staticmethod
4589 def _merge_shot_text_parts(left: str, right: str) -> str:
4590 left = left.strip()
4591 right = right.strip()
4592 if not left:
4593 return right
4594 if not right:
4595 return left
4596 return f"{left}\n\n{right}"
4597
4598 @classmethod
4599 def _merge_shot_payloads(
4600 cls,
4601 upper_shot: dict[str, Any],
4602 lower_shot: dict[str, Any],
4603 *,
4604 merged_summary: str | None = None,
4605 ) -> dict[str, Any]:
4606 merged = dict(upper_shot)
4607 if merged_summary is not None:
4608 merged["summary"] = merged_summary.strip()
4609 else:
4610 merged["summary"] = cls._merge_shot_text_parts(
4611 str(upper_shot.get("summary") or ""),
4612 str(lower_shot.get("summary") or ""),
4613 )
4614 upper_caption = upper_shot.get("caption")
4615 lower_caption = lower_shot.get("caption")
4616 if isinstance(upper_caption, str) or isinstance(lower_caption, str):
4617 merged["caption"] = cls._merge_shot_text_parts(
4618 upper_caption if isinstance(upper_caption, str) else "",
4619 lower_caption if isinstance(lower_caption, str) else "",
4620 )
4621 return merged
4622
4623 @staticmethod
4624 def _shot_has_generated_media(shot: dict[str, Any]) -> bool:
4625 if shot.get("artifact_path") or shot.get("artifact_url"):
4626 return True
4627 status = str(shot.get("status") or "")
4628 return status in {"queued", "generated", "review_pass", "review_fail", "approved"}
4629
4630 @staticmethod
4631 def _parse_json_body_payload(request: WsRequest) -> dict[str, Any] | None:
4632 raw_body = getattr(request, "body", None)
4633 if isinstance(raw_body, (bytes, bytearray)) and raw_body:
4634 try:
4635 payload = json.loads(raw_body)
4636 except (json.JSONDecodeError, UnicodeDecodeError):
4637 payload = None
4638 if isinstance(payload, dict):
4639 return payload
4640 encoded = request.headers.get("X-Nanobot-Body")
4641 if encoded:
4642 try:
4643 payload = json.loads(base64.b64decode(encoded, validate=True))
4644 except (json.JSONDecodeError, UnicodeDecodeError, binascii.Error, ValueError):
4645 payload = None
4646 if isinstance(payload, dict):
4647 return payload
4648 return None
4649
4650 @staticmethod
4651 def _parse_split_shot_body(request: WsRequest) -> dict[str, Any] | None:
4652 payload = WebSocketChannel._parse_json_body_payload(request)
4653 result: dict[str, Any] = {}
4654 if isinstance(payload, dict):
4655 for key in ("before_text", "after_text", "cursor_pos"):
4656 if key in payload:
4657 result[key] = payload[key]
4658 query = _parse_query(request.path)
4659 for key in ("before_text", "after_text", "cursor_pos"):
4660 value = _query_first(query, key)
4661 if value is not None:
4662 if key == "cursor_pos":
4663 try:
4664 result[key] = int(value)
4665 except ValueError:
4666 pass
4667 else:
4668 result[key] = value
4669 return result or None
4670
4671 @classmethod
4672 def _build_split_shot_payloads(
4673 cls,
4674 shot: dict[str, Any],
4675 *,
4676 before_text: str,
4677 after_text: str,
4678 ) -> tuple[dict[str, Any], dict[str, Any]]:
4679 upper = dict(shot)
4680 lower = dict(shot)
4681 upper["summary"] = before_text
4682 lower["summary"] = after_text
4683 caption = shot.get("caption")
4684 if isinstance(caption, str):
4685 upper["caption"] = before_text
4686 lower["caption"] = after_text
4687 for field in (
4688 "artifact_path",
4689 "artifact_url",
4690 "last_review",
4691 "review_notes",
4692 "generation_error",
4693 "last_job_id",
4694 "approved_at",
4695 "remote_result",
4696 ):
4697 upper.pop(field, None)
4698 lower.pop(field, None)
4699 upper["status"] = "prompt_ready"
4700 lower["status"] = "prompt_ready"
4701 lower["cut"] = True
4702 return upper, lower
4703
4704 _SHOT_DURATION_MIN_SEC = 1
4705 _SHOT_DURATION_MAX_SEC = 10
4706 _SHOT_DURATION_DEFAULT_SEC = 5
4707 # Must stay aligned with webui AspectRatioPicker presets.
4708 _VALID_VIDEO_SIZES = frozenset(
4709 {
4710 (1280, 736), # 16:9
4711 (736, 736), # 1:1
4712 (736, 1280), # 9:16
4713 }
4714 )
4715
4716 @classmethod
4717 def _clamp_shot_duration_sec(cls, value: float) -> int:
4718 return max(
4719 cls._SHOT_DURATION_MIN_SEC,
4720 min(cls._SHOT_DURATION_MAX_SEC, int(round(value))),
4721 )
4722
4723 @staticmethod
4724 def _parse_shot_duration_body(request: WsRequest) -> int | None:
4725 raw_body = getattr(request, "body", None)
4726 if isinstance(raw_body, (bytes, bytearray)) and raw_body:
4727 try:
4728 payload = json.loads(raw_body)
4729 except (json.JSONDecodeError, UnicodeDecodeError):
4730 payload = None
4731 if isinstance(payload, dict):
4732 value = payload.get("duration_sec")
4733 if value is None:
4734 value = payload.get("duration_seconds")
4735 try:
4736 return int(round(float(value)))
4737 except (TypeError, ValueError):
4738 pass
4739 encoded = request.headers.get("X-Nanobot-Body")
4740 if encoded:
4741 try:
4742 payload = json.loads(base64.b64decode(encoded, validate=True))
4743 except (json.JSONDecodeError, UnicodeDecodeError, binascii.Error, ValueError):
4744 payload = None
4745 if isinstance(payload, dict):
4746 value = payload.get("duration_sec")
4747 if value is None:
4748 value = payload.get("duration_seconds")
4749 try:
4750 return int(round(float(value)))
4751 except (TypeError, ValueError):
4752 pass
4753 query = _parse_query(request.path)
4754 for key in ("duration_sec", "duration_seconds"):
4755 value = _query_first(query, key)
4756 if value is None:
4757 continue
4758 try:
4759 return int(round(float(value)))
4760 except (TypeError, ValueError):
4761 continue
4762 return None
4763
4764 @staticmethod
4765 def _parse_merge_shot_body(request: WsRequest) -> str | None:
4766 raw_body = getattr(request, "body", None)
4767 if isinstance(raw_body, (bytes, bytearray)) and raw_body:
4768 try:
4769 payload = json.loads(raw_body)
4770 except (json.JSONDecodeError, UnicodeDecodeError):
4771 payload = None
4772 if isinstance(payload, dict):
4773 value = payload.get("merged_text")
4774 if isinstance(value, str):
4775 return value
4776 encoded = request.headers.get("X-Nanobot-Body")
4777 if encoded:
4778 try:
4779 payload = json.loads(base64.b64decode(encoded, validate=True))
4780 except (json.JSONDecodeError, UnicodeDecodeError, binascii.Error, ValueError):
4781 payload = None
4782 if isinstance(payload, dict):
4783 value = payload.get("merged_text")
4784 if isinstance(value, str):
4785 return value
4786 value = _query_first(_parse_query(request.path), "merged_text")
4787 if value is not None:
4788 return value
4789 return None
4790
4791 def _rebuild_workplace_state_shots(self, work_id: str, state: dict[str, Any]) -> None:
4792 paths = self._workplace_paths(work_id)
4793 if paths is None:
4794 return
4795 state["shots"] = {}
4796 for shot_path in sorted(paths["shots"].glob("shot_*.json")):
4797 shot = self._read_json_file(shot_path, {})
4798 if isinstance(shot, dict) and shot.get("shot_id"):
4799 self._sync_state_shot_row(state, shot)
4800
4801 def _reconcile_workplace_state_shots(self, work_id: str, state: dict[str, Any]) -> bool:
4802 """Sync ``state.shots`` from on-disk shot JSON. Persists when changed."""
4803 before = json.dumps(state.get("shots"), sort_keys=True, default=str)
4804 self._rebuild_workplace_state_shots(work_id, state)
4805 after = json.dumps(state.get("shots"), sort_keys=True, default=str)
4806 if before == after:
4807 return False
4808 self._save_workplace_state(work_id, state)
4809 return True
4810
4811 def _apply_workplace_shot_merge_up(
4812 self,
4813 session_key: str,
4814 shot_id: int,
4815 merged_text: str | None = None,
4816 ) -> tuple[str, dict[str, Any]] | None:
4817 if shot_id < 2:
4818 raise ValueError("cannot merge the first beat")
4819 work_id = self._resolve_work_id_for_session(session_key)
4820 if not work_id:
4821 return None
4822 state = self._load_workplace_state(work_id)
4823 self._assert_beats_editable(work_id, state)
4824 profile = self._load_workplace_story_profile(work_id)
4825 beats = profile.get("beats")
4826 if not isinstance(beats, list) or not beats:
4827 raise ValueError("story_profile.beats is empty")
4828 beat_items = [beat for beat in beats if isinstance(beat, dict)]
4829 index = self._find_beat_index(beat_items, shot_id)
4830 if index < 1:
4831 raise ValueError("cannot merge the first beat")
4832 upper = beat_items[index - 1]
4833 lower = beat_items[index]
4834 if merged_text is not None and merged_text.strip():
4835 merged_summary = merged_text.strip()
4836 else:
4837 merged_summary = self._merge_shot_text_parts(
4838 str(upper.get("summary") or ""),
4839 str(lower.get("summary") or ""),
4840 )
4841 upper["summary"] = merged_summary
4842 beat_items.pop(index)
4843 profile["beats"] = self._normalize_story_profile_beats(beat_items)
4844 self._clear_workplace_shot_files(work_id, state)
4845 self._save_workplace_story_profile(work_id, profile, state)
4846 self._save_workplace_state(work_id, state)
4847 self._schedule_workplace_beats_edit_instruction(session_key, work_id=work_id)
4848 workplace = self._build_workplace_payload(session_key)
4849 return work_id, workplace
4850
4851 def _apply_workplace_shot_remove(
4852 self,
4853 session_key: str,
4854 shot_id: int,
4855 ) -> tuple[str, dict[str, Any]] | None:
4856 work_id = self._resolve_work_id_for_session(session_key)
4857 if not work_id:
4858 return None
4859 state = self._load_workplace_state(work_id)
4860 self._assert_beats_editable(work_id, state)
4861 profile = self._load_workplace_story_profile(work_id)
4862 beats = profile.get("beats")
4863 if not isinstance(beats, list) or not beats:
4864 raise ValueError("story_profile.beats is empty")
4865 beat_items = [beat for beat in beats if isinstance(beat, dict)]
4866 if len(beat_items) <= 1:
4867 raise ValueError("cannot remove the last beat")
4868 index = self._find_beat_index(beat_items, shot_id)
4869 beat_items.pop(index)
4870 profile["beats"] = self._normalize_story_profile_beats(beat_items)
4871 self._clear_workplace_shot_files(work_id, state)
4872 self._save_workplace_story_profile(work_id, profile, state)
4873 self._save_workplace_state(work_id, state)
4874 self._schedule_workplace_beats_edit_instruction(session_key, work_id=work_id)
4875 workplace = self._build_workplace_payload(session_key)
4876 return work_id, workplace
4877
4878 def _apply_workplace_shot_split_shot(
4879 self,
4880 session_key: str,
4881 shot_id: int,
4882 split_payload: dict[str, Any] | None = None,
4883 ) -> tuple[str, dict[str, Any]] | None:
4884 work_id = self._resolve_work_id_for_session(session_key)
4885 if not work_id:
4886 return None
4887 state = self._load_workplace_state(work_id)
4888 self._assert_beats_editable(work_id, state)
4889 profile = self._load_workplace_story_profile(work_id)
4890 beats = profile.get("beats")
4891 if not isinstance(beats, list) or not beats:
4892 raise ValueError("story_profile.beats is empty")
4893 beat_items = [beat for beat in beats if isinstance(beat, dict)]
4894 index = self._find_beat_index(beat_items, shot_id)
4895 beat = beat_items[index]
4896
4897 payload = split_payload or {}
4898 before_text = payload.get("before_text")
4899 after_text = payload.get("after_text")
4900 if before_text is None or after_text is None:
4901 if "cursor_pos" not in payload:
4902 raise ValueError("before_text and after_text are required")
4903 summary = str(beat.get("summary") or "")
4904 try:
4905 cursor_pos = int(payload["cursor_pos"])
4906 except (TypeError, ValueError) as exc:
4907 raise ValueError("cursor_pos must be an integer") from exc
4908 cursor_pos = max(0, min(cursor_pos, len(summary)))
4909 before_text = summary[:cursor_pos].rstrip()
4910 after_text = summary[cursor_pos:].lstrip()
4911 before_text = str(before_text).rstrip()
4912 after_text = str(after_text).lstrip()
4913 if not before_text or not after_text:
4914 raise ValueError("请把光标放在镜头文本中间再拆分(不能在开头或末尾)")
4915
4916 beat_items[index] = {"shot_id": shot_id, "summary": before_text}
4917 beat_items.insert(index + 1, {"shot_id": shot_id + 1, "summary": after_text})
4918 profile["beats"] = self._normalize_story_profile_beats(beat_items)
4919 self._clear_workplace_shot_files(work_id, state)
4920 self._save_workplace_story_profile(work_id, profile, state)
4921 self._save_workplace_state(work_id, state)
4922 self._schedule_workplace_beats_edit_instruction(session_key, work_id=work_id)
4923 workplace = self._build_workplace_payload(session_key)
4924 return work_id, workplace
4925
4926 def _sync_state_shot_row(self, state: dict[str, Any], shot: dict[str, Any]) -> None:
4927 shots = state.setdefault("shots", {})
4928 if not isinstance(shots, dict):
4929 shots = {}
4930 state["shots"] = shots
4931 shot_id = int(shot.get("shot_id") or 0)
4932 if shot_id <= 0:
4933 return
4934 shot_key = str(shot.get("shot_key") or f"shot_{shot_id:03d}")
4935 shots[shot_key] = {
4936 "shot_id": shot_id,
4937 "status": shot.get("status"),
4938 "summary": shot.get("summary") or "",
4939 "cut": bool(shot.get("cut", True)),
4940 "has_shot_spec": self._shot_has_caption(shot),
4941 "has_artifact": bool(shot.get("artifact_path") or shot.get("artifact_url")),
4942 "artifact_path": shot.get("artifact_path"),
4943 "artifact_url": shot.get("artifact_url"),
4944 "last_review": shot.get("last_review"),
4945 "review_notes": shot.get("review_notes") or "",
4946 "updated_at": shot.get("updated_at"),
4947 }
4948
4949 @staticmethod
4950 def _shot_has_video_artifact(shot: dict[str, Any]) -> bool:
4951 for key in ("artifact_url", "artifact_path"):
4952 value = shot.get(key)
4953 if isinstance(value, str) and value.strip():
4954 return True
4955 remote_result = shot.get("remote_result")
4956 if isinstance(remote_result, dict):
4957 video_path = remote_result.get("video_path")
4958 if isinstance(video_path, str) and video_path.strip():
4959 return True
4960 return False
4961
4962 def _shot_ready_to_accept(self, shot: dict[str, Any]) -> bool:
4963 status = str(shot.get("status") or "")
4964 if status not in {"generated", "review_pass"}:
4965 return False
4966 return self._shot_has_video_artifact(shot)
4967
4968 def _raise_accept_all_unavailable(self, work_id: str) -> None:
4969 rows = self._iter_workplace_shots(work_id)
4970 video_rows = [
4971 (shot_id, shot)
4972 for shot_id, shot in rows
4973 if self._shot_has_video_artifact(shot)
4974 or str(shot.get("status") or "") in {"generated", "review_pass", "approved"}
4975 ]
4976 detail = ", ".join(
4977 f"{shot_id}:{status}"
4978 for shot_id, shot in rows
4979 for status in [str(shot.get("status") or "")]
4980 )
4981 suffix = f" (work_id={work_id}, shots=[{detail}])" if detail else f" (work_id={work_id})"
4982 if video_rows and all(
4983 str(shot.get("status") or "") == "approved" for _shot_id, shot in video_rows
4984 ):
4985 raise ValueError(f"all shots with video are already accepted{suffix}")
4986 raise ValueError(f"no shots with video are ready to accept{suffix}")
4987
4988 def _apply_workplace_accept_all(
4989 self,
4990 session_key: str,
4991 ) -> tuple[str, dict[str, Any], list[int]]:
4992 work_id = self._resolve_work_id_for_session(session_key)
4993 if not work_id:
4994 raise ValueError("director work not found")
4995 accepted: list[int] = []
4996 for shot_id, shot in self._iter_workplace_shots(work_id):
4997 if not self._shot_ready_to_accept(shot):
4998 continue
4999 self._apply_workplace_review(session_key, shot_id, verdict="accept")
5000 accepted.append(shot_id)
5001 if not accepted:
5002 self._raise_accept_all_unavailable(work_id)
5003 return work_id, self._build_workplace_payload(session_key), accepted
5004
5005 def _apply_workplace_review(
5006 self,
5007 session_key: str,
5008 shot_id: int,
5009 *,
5010 verdict: str,
5011 feedback: str | None = None,
5012 review_source: str = "human",
5013 ) -> tuple[str, dict[str, Any]] | None:
5014 if self._session_manager is None:
5015 return None
5016 loaded = self._load_workplace_shot(session_key, shot_id)
5017 if loaded is None:
5018 return None
5019 work_id, _shot_path, _state, _shot = loaded
5020 tool = ReviewShotTool(workspace=self._session_manager.workspace)
5021 tool._apply_shot_review(
5022 work_id,
5023 shot_id,
5024 verdict=verdict,
5025 review_source=review_source,
5026 feedback=feedback,
5027 )
5028 if verdict == "accept":
5029 self._maybe_advance_approved_shot_memory(session_key, work_id, shot_id)
5030 return work_id, self._build_workplace_payload(session_key)
5031
5032 def _handle_workplace_status(self, request: WsRequest, key: str) -> Response:
5033 if not self._check_api_token(request):
5034 return _http_error(401, "Unauthorized")
5035 legacy_err = self._legacy_webui_session_key_error(key)
5036 if legacy_err is not None:
5037 return legacy_err
5038 decoded_key = self._resolve_webui_api_session_key(key, request)
5039 if decoded_key is None:
5040 return _http_error(404, "session not found")
5041 payload = self._build_workplace_payload(decoded_key)
5042 self._schedule_auto_generate_continue(decoded_key)
5043 return _http_json_response(payload)
5044
5045 async def _publish_workplace_update(self, session_key: str) -> None:
5046 if self._session_manager is None:
5047 return
5048 if not self._is_webui_session_key(session_key):
5049 return
5050 chat_id = webui_wire_chat_id(session_key)
5051 if not chat_id:
5052 return
5053 await self.send(
5054 OutboundMessage(
5055 channel="websocket",
5056 chat_id=chat_id,
5057 content="",
5058 metadata={
5059 "_workplace_event": "updated",
5060 "workplace": self._build_workplace_payload(session_key),
5061 "work_id": self._resolve_work_id_for_session(session_key),
5062 },
5063 )
5064 )
5065
5066 def _schedule_coro(
5067 self,
5068 factory: Callable[[], Any],
5069 *,
5070 warning: str | None = None,
5071 ) -> bool:
5072 """Schedule ``factory()`` on the websocket loop, including from worker threads.
5073
5074 ``send()`` continues one-click generate via ``asyncio.to_thread``. That
5075 worker has no running loop, so ``get_running_loop()`` fails and workplace
5076 injections (start_generation, revision, abort) would otherwise be dropped.
5077 """
5078 try:
5079 loop = asyncio.get_running_loop()
5080 in_loop_thread = True
5081 except RuntimeError:
5082 loop = self._loop
5083 in_loop_thread = False
5084 if loop is None or loop.is_closed():
5085 if warning:
5086 logger.warning(warning)
5087 return False
5088 if not in_loop_thread and not loop.is_running():
5089 if warning:
5090 logger.warning(warning)
5091 return False
5092 coro = factory()
5093 if in_loop_thread:
5094 loop.create_task(coro)
5095 return True
5096 asyncio.run_coroutine_threadsafe(coro, loop)
5097 return True
5098
5099 def _schedule_publish_workplace_update(self, session_key: str) -> None:
5100 self._schedule_coro(lambda: self._publish_workplace_update(session_key))
5101
5102 def _schedule_workplace_revision_instruction(
5103 self,
5104 session_key: str,
5105 *,
5106 work_id: str,
5107 shot_id: int,
5108 feedback: str,
5109 ) -> None:
5110 content = (
5111 "Internal workplace revision task. Execute silently.\n\n"
5112 f"Director work_id: `{work_id}`\n"
5113 f"Target shot_id: `{shot_id}`\n"
5114 f"User revision feedback:\n{feedback}\n\n"
5115 "Required actions, in order:\n"
5116 "1. Use director tools to inspect the current target shot.\n"
5117 "2. Rewrite/update only this shot's prompt/spec so it directly incorporates the feedback.\n"
5118 "3. Recalculate the correct `reference_shot_ids` for this revised shot.\n"
5119 "4. Immediately call `generate_echo_shot` for this same shot_id with the recalculated references.\n\n"
5120 "Strict constraints:\n"
5121 "- Do not ask the user any question.\n"
5122 "- Do not acknowledge, summarize, or explain the feedback to the user.\n"
5123 "- Do not send any user-facing message before or after the tool calls.\n"
5124 "- Do not regenerate other shots unless the feedback explicitly requires cross-shot changes.\n"
5125 "- Do not stop after recording state; the regeneration tool call is mandatory."
5126 )
5127 msg = InboundMessage(
5128 channel="system",
5129 sender_id="workplace",
5130 chat_id=session_key,
5131 content=content,
5132 session_key_override=session_key,
5133 metadata={
5134 "injected_event": "workplace_shot_revision",
5135 "injected_role": "user",
5136 "silent": True,
5137 "work_id": work_id,
5138 "shot_id": shot_id,
5139 },
5140 )
5141 self._schedule_coro(
5142 lambda: self.bus.publish_inbound(msg),
5143 warning="websocket: unable to schedule workplace revision instruction",
5144 )
5145
5146 def _memory_review_workflow_enabled(self) -> bool:
5147 return self._tools_config.memory_review.enabled
5148
5149 def _maybe_advance_approved_shot_memory(
5150 self,
5151 session_key: str,
5152 work_id: str,
5153 shot_id: int,
5154 ) -> bool:
5155 """Advance once, only after both the video and selected Memory are accepted."""
5156 if not self._memory_review_workflow_enabled():
5157 return False
5158 if self._workplace_auto_generate_active(session_key):
5159 # Auto-generation already submits the next shot via generate_all.
5160 return False
5161 loaded = self._load_workplace_shot(session_key, shot_id)
5162 if loaded is None:
5163 return False
5164 _work_id, shot_path, state, shot = loaded
5165 review = shot.get("memory_review")
5166 if str(shot.get("status") or "") != "approved":
5167 return False
5168 if not isinstance(review, dict) or review.get("status") != "approved":
5169 return False
5170 if review.get("advance_complete") is True:
5171 return False
5172
5173 from nanobot.director.r2v_memory_workflow import (
5174 approve_review_and_prepare_next,
5175 )
5176
5177 next_shot_id = approve_review_and_prepare_next(
5178 workspace=(
5179 self._session_manager.workspace
5180 if self._session_manager is not None
5181 else Path.cwd()
5182 ),
5183 work_id=work_id,
5184 shot_id=shot_id,
5185 )
5186 if next_shot_id is not None:
5187 # Interactive mode pauses here. The agent proposes a draft from
5188 # text profiles, then Build Memory is the sole approval gate.
5189 self._schedule_workplace_memory_recommendation_instruction(
5190 session_key,
5191 work_id=work_id,
5192 shot_id=int(next_shot_id),
5193 )
5194
5195 current = self._read_json_file(shot_path, {})
5196 if not isinstance(current, dict):
5197 current = shot
5198 current_review = current.get("memory_review")
5199 if isinstance(current_review, dict):
5200 current_review["advance_complete"] = True
5201 current_review["advance_next_shot_id"] = next_shot_id
5202 current["memory_review"] = current_review
5203 self._write_json_file(shot_path, current)
5204 latest_state = self._load_workplace_state(work_id)
5205 self._sync_state_shot_row(latest_state, current)
5206 self._save_workplace_state(work_id, latest_state)
5207 return True
5208
5209 def _apply_memory_review_action(
5210 self,
5211 session_key: str,
5212 shot_id: int,
5213 *,
5214 action: str,
5215 review_id: str,
5216 attempt: int,
5217 memory_id: str | None = None,
5218 retained_memory_ids: list[str] | None = None,
5219 ) -> tuple[str, dict[str, Any], bool]:
5220 loaded = self._load_workplace_shot(session_key, shot_id)
5221 if loaded is None:
5222 raise ValueError(f"shot {shot_id} not found")
5223 work_id, shot_path, state, shot = loaded
5224 review = shot.get("memory_review")
5225 if not isinstance(review, dict):
5226 raise ValueError(f"shot {shot_id} has no memory review")
5227 if action == "approve" and str(review.get("status") or "") == "awaiting_method":
5228 paths = self._workplace_paths(work_id)
5229 current_bank = self._read_json_file(paths["memory_bank"], {})
5230 review["status"] = "awaiting_review"
5231 review["selection_mode"] = "none"
5232 review["selections"] = []
5233 review["retained_memory_ids"] = []
5234 review["proposed_bank"] = (
5235 current_bank if isinstance(current_bank, dict) else {}
5236 )
5237 review["previous_shot"] = None
5238 updated_at = (
5239 datetime.now(timezone.utc)
5240 .isoformat(timespec="seconds")
5241 .replace("+00:00", "Z")
5242 )
5243 if action == "approve":
5244 changed = approve_memory_review(
5245 review,
5246 review_id=review_id,
5247 attempt=attempt,
5248 updated_at=updated_at,
5249 retained_memory_ids=retained_memory_ids,
5250 )
5251 if changed:
5252 from nanobot.director.r2v_memory_workflow import (
5253 stage_after_memory_advance,
5254 )
5255
5256 state["stage"] = stage_after_memory_advance(state, has_next=True)
5257 elif action == "reselect":
5258 changed = reselect_memory_review(
5259 review,
5260 review_id=review_id,
5261 attempt=attempt,
5262 updated_at=updated_at,
5263 memory_id=memory_id,
5264 )
5265 if changed:
5266 state["stage"] = "awaiting_memory_review"
5267 else:
5268 raise ValueError(f"unsupported memory review action: {action}")
5269 if changed:
5270 shot["memory_review"] = review
5271 self._write_json_file(shot_path, shot)
5272 self._sync_state_shot_row(state, shot)
5273 self._save_workplace_state(work_id, state)
5274 if action == "approve":
5275 self._maybe_advance_approved_shot_memory(
5276 session_key, work_id, shot_id
5277 )
5278 return work_id, self._build_workplace_payload(session_key), changed
5279
5280 async def _rerun_memory_selection(
5281 self,
5282 session_key: str,
5283 work_id: str,
5284 shot_id: int,
5285 memory_id: str | None = None,
5286 selection_mode: str = "vlm",
5287 ) -> None:
5288 try:
5289 runner_kwargs: dict[str, Any] = {
5290 "workspace": (
5291 self._session_manager.workspace
5292 if self._session_manager is not None
5293 else Path.cwd()
5294 ),
5295 "work_id": work_id,
5296 "shot_id": shot_id,
5297 "target_memory_id": memory_id,
5298 }
5299 if selection_mode != "vlm":
5300 runner_kwargs["selection_mode"] = selection_mode
5301 await asyncio.to_thread(
5302 self._memory_review_runner,
5303 **runner_kwargs,
5304 )
5305 except Exception as exc:
5306 logger.exception(
5307 "websocket: memory reselection failed work_id={} shot_id={}",
5308 work_id,
5309 shot_id,
5310 )
5311 try:
5312 from nanobot.director.memory_coordinator import (
5313 initialize_memory_review_method_prompt,
5314 )
5315
5316 await asyncio.to_thread(
5317 initialize_memory_review_method_prompt,
5318 workspace=(
5319 self._session_manager.workspace
5320 if self._session_manager is not None
5321 else Path.cwd()
5322 ),
5323 work_id=work_id,
5324 shot_id=shot_id,
5325 error=f"{selection_mode.upper()} memory selection failed: {exc}",
5326 )
5327 except Exception:
5328 logger.exception(
5329 "websocket: failed to persist memory selection error "
5330 "work_id={} shot_id={}",
5331 work_id,
5332 shot_id,
5333 )
5334 await self._publish_workplace_update(session_key)
5335
5336 def _handle_memory_review_action(
5337 self,
5338 request: WsRequest,
5339 key: str,
5340 shot_id: int,
5341 action: str,
5342 ) -> Response:
5343 resolved = self._resolve_workplace_request(request, key)
5344 if not isinstance(resolved, tuple):
5345 return resolved
5346 decoded_key, _work_id = resolved
5347 payload = self._parse_json_body_payload(request)
5348 if not isinstance(payload, dict):
5349 return _http_error(400, "memory review action body is required")
5350 review_id = str(payload.get("review_id") or "").strip()
5351 try:
5352 attempt = int(payload.get("attempt"))
5353 except (TypeError, ValueError):
5354 attempt = 0
5355 if not review_id or attempt <= 0:
5356 return _http_error(400, "review_id and attempt are required")
5357 memory_id_value = payload.get("memory_id")
5358 memory_id = (
5359 str(memory_id_value).strip()
5360 if isinstance(memory_id_value, str) and memory_id_value.strip()
5361 else None
5362 )
5363 retained_memory_ids: list[str] | None = None
5364 if action == "approve" and "retained_memory_ids" in payload:
5365 raw_retained = payload.get("retained_memory_ids")
5366 if not isinstance(raw_retained, list) or not all(
5367 isinstance(value, str) and value.strip()
5368 for value in raw_retained
5369 ):
5370 return _http_error(
5371 400, "retained_memory_ids must be a list of memory IDs"
5372 )
5373 retained_memory_ids = [value.strip() for value in raw_retained]
5374 if action == "select-mode":
5375 selection_mode = str(payload.get("selection_mode") or "").strip()
5376 if selection_mode not in {"manual", "vlm"}:
5377 return _http_error(400, "selection_mode must be manual or vlm")
5378 loaded = self._load_workplace_shot(decoded_key, shot_id)
5379 if loaded is None:
5380 return _http_error(404, f"shot {shot_id} not found")
5381 work_id, shot_path, state, shot = loaded
5382 review = shot.get("memory_review")
5383 if not isinstance(review, dict):
5384 return _http_error(400, f"shot {shot_id} has no memory review")
5385 if (
5386 str(review.get("review_id") or "") != review_id
5387 or int(review.get("attempt") or 0) != attempt
5388 ):
5389 return _http_error(409, "stale memory review attempt")
5390 if str(review.get("status") or "") != "awaiting_method":
5391 return _http_error(409, "memory selection method was already chosen")
5392 review["status"] = "selecting"
5393 review["selection_mode"] = selection_mode
5394 review["updated_at"] = (
5395 datetime.now(timezone.utc)
5396 .isoformat(timespec="seconds")
5397 .replace("+00:00", "Z")
5398 )
5399 shot["memory_review"] = review
5400 self._write_json_file(shot_path, shot)
5401 self._sync_state_shot_row(state, shot)
5402 self._save_workplace_state(work_id, state)
5403 try:
5404 loop = asyncio.get_running_loop()
5405 loop.create_task(
5406 self._rerun_memory_selection(
5407 decoded_key,
5408 work_id,
5409 shot_id,
5410 selection_mode=selection_mode,
5411 )
5412 )
5413 except RuntimeError:
5414 return _http_error(500, "memory selection worker is unavailable")
5415 return _http_json_response(
5416 {
5417 "ok": True,
5418 "action": "memory_review_select_mode",
5419 "work_id": work_id,
5420 "shot_id": shot_id,
5421 "changed": True,
5422 "workplace": self._build_workplace_payload(decoded_key),
5423 }
5424 )
5425 if action == "manual-select":
5426 try:
5427 timestamp_sec = float(payload.get("timestamp_sec"))
5428 except (TypeError, ValueError):
5429 return _http_error(400, "timestamp_sec is required")
5430 if memory_id is None or timestamp_sec < 0:
5431 return _http_error(400, "memory_id and non-negative timestamp_sec are required")
5432 try:
5433 select_manual_memory_frame(
5434 workspace=(
5435 self._session_manager.workspace
5436 if self._session_manager is not None
5437 else Path.cwd()
5438 ),
5439 work_id=_work_id,
5440 shot_id=shot_id,
5441 review_id=review_id,
5442 attempt=attempt,
5443 memory_id=memory_id,
5444 timestamp_sec=timestamp_sec,
5445 )
5446 loaded = self._load_workplace_shot(decoded_key, shot_id)
5447 if loaded is None:
5448 raise ValueError(f"shot {shot_id} not found")
5449 work_id, _shot_path, state, shot = loaded
5450 self._sync_state_shot_row(state, shot)
5451 self._save_workplace_state(work_id, state)
5452 workplace = self._build_workplace_payload(decoded_key)
5453 except MemoryReviewConflict as exc:
5454 return _http_error(409, str(exc))
5455 except (OSError, RuntimeError, ValueError) as exc:
5456 return _http_error(400, str(exc))
5457 try:
5458 loop = asyncio.get_running_loop()
5459 loop.create_task(self._publish_workplace_update(decoded_key))
5460 except RuntimeError:
5461 pass
5462 return _http_json_response(
5463 {
5464 "ok": True,
5465 "action": "memory_review_manual_select",
5466 "work_id": work_id,
5467 "shot_id": shot_id,
5468 "changed": True,
5469 "workplace": workplace,
5470 }
5471 )
5472 try:
5473 work_id, workplace, changed = self._apply_memory_review_action(
5474 decoded_key,
5475 shot_id,
5476 action=action,
5477 review_id=review_id,
5478 attempt=attempt,
5479 memory_id=memory_id,
5480 retained_memory_ids=retained_memory_ids,
5481 )
5482 except MemoryReviewConflict as exc:
5483 return _http_error(409, str(exc))
5484 except ValueError as exc:
5485 return _http_error(400, str(exc))
5486 if (
5487 changed
5488 and action == "reselect"
5489 and self._memory_review_workflow_enabled()
5490 ):
5491 asyncio.create_task(
5492 self._rerun_memory_selection(
5493 decoded_key, work_id, shot_id, memory_id
5494 )
5495 )
5496 try:
5497 loop = asyncio.get_running_loop()
5498 loop.create_task(self._publish_workplace_update(decoded_key))
5499 except RuntimeError:
5500 pass
5501 return _http_json_response(
5502 {
5503 "ok": True,
5504 "action": f"memory_review_{action}",
5505 "work_id": work_id,
5506 "shot_id": shot_id,
5507 "changed": changed,
5508 "workplace": workplace,
5509 }
5510 )
5511
5512 def _handle_workplace_shot_accept(self, request: WsRequest, key: str, shot_id: int) -> Response:
5513 if not self._check_api_token(request):
5514 return _http_error(401, "Unauthorized")
5515 legacy_err = self._legacy_webui_session_key_error(key)
5516 if legacy_err is not None:
5517 return legacy_err
5518 decoded_key = self._resolve_webui_api_session_key(key, request)
5519 if decoded_key is None:
5520 return _http_error(404, "session not found")
5521 try:
5522 reviewed = self._apply_workplace_review(decoded_key, shot_id, verdict="accept")
5523 except ValueError as exc:
5524 return _http_error(400, str(exc))
5525 if reviewed is None:
5526 return _http_error(404, "shot not found")
5527 work_id, workplace = reviewed
5528 try:
5529 loop = asyncio.get_running_loop()
5530 loop.create_task(self._publish_workplace_update(decoded_key))
5531 except RuntimeError:
5532 pass
5533 return _http_json_response(
5534 {
5535 "ok": True,
5536 "work_id": work_id,
5537 "shot_id": shot_id,
5538 "status": "approved",
5539 "workplace": workplace,
5540 }
5541 )
5542
5543 def _handle_workplace_shot_accept_all(self, request: WsRequest, key: str) -> Response:
5544 logger.info("workplace HTTP shots/accept-all session_key={}", key)
5545 if not self._check_api_token(request):
5546 return _http_error(401, "Unauthorized")
5547 legacy_err = self._legacy_webui_session_key_error(key)
5548 if legacy_err is not None:
5549 return legacy_err
5550 decoded_key = self._resolve_webui_api_session_key(key, request)
5551 if decoded_key is None:
5552 return _http_error(404, "session not found")
5553 try:
5554 work_id, workplace, accepted_shot_ids = self._apply_workplace_accept_all(decoded_key)
5555 except ValueError as exc:
5556 return _http_error(400, str(exc))
5557 try:
5558 loop = asyncio.get_running_loop()
5559 loop.create_task(self._publish_workplace_update(decoded_key))
5560 except RuntimeError:
5561 pass
5562 return _http_json_response(
5563 {
5564 "ok": True,
5565 "work_id": work_id,
5566 "accepted_shot_ids": accepted_shot_ids,
5567 "workplace": workplace,
5568 }
5569 )
5570
5571 def _handle_workplace_shot_merge_up(self, request: WsRequest, key: str, shot_id: int) -> Response:
5572 logger.info("workplace HTTP merge-up session_key={} shot_id={}", key, shot_id)
5573 if not self._check_api_token(request):
5574 return _http_error(401, "Unauthorized")
5575 legacy_err = self._legacy_webui_session_key_error(key)
5576 if legacy_err is not None:
5577 return legacy_err
5578 decoded_key = self._resolve_webui_api_session_key(key, request)
5579 if decoded_key is None:
5580 return _http_error(404, "session not found")
5581 merged_text = self._parse_merge_shot_body(request)
5582 try:
5583 merged = self._apply_workplace_shot_merge_up(
5584 decoded_key,
5585 shot_id,
5586 merged_text=merged_text,
5587 )
5588 except ValueError as exc:
5589 return _http_error(400, str(exc))
5590 if merged is None:
5591 return _http_error(404, "beat not found")
5592 work_id, workplace = merged
5593 try:
5594 loop = asyncio.get_running_loop()
5595 loop.create_task(self._publish_workplace_update(decoded_key))
5596 except RuntimeError:
5597 pass
5598 return _http_json_response(
5599 {
5600 "ok": True,
5601 "work_id": work_id,
5602 "merged_shot_id": shot_id,
5603 "into_shot_id": shot_id - 1,
5604 "workplace": workplace,
5605 }
5606 )
5607
5608 def _handle_workplace_shot_remove_shot(self, request: WsRequest, key: str, shot_id: int) -> Response:
5609 logger.info("workplace HTTP remove-shot session_key={} shot_id={}", key, shot_id)
5610 if not self._check_api_token(request):
5611 return _http_error(401, "Unauthorized")
5612 legacy_err = self._legacy_webui_session_key_error(key)
5613 if legacy_err is not None:
5614 return legacy_err
5615 decoded_key = self._resolve_webui_api_session_key(key, request)
5616 if decoded_key is None:
5617 return _http_error(404, "session not found")
5618 try:
5619 removed = self._apply_workplace_shot_remove(decoded_key, shot_id)
5620 except ValueError as exc:
5621 return _http_error(400, str(exc))
5622 if removed is None:
5623 return _http_error(404, "beat not found")
5624 work_id, workplace = removed
5625 try:
5626 loop = asyncio.get_running_loop()
5627 loop.create_task(self._publish_workplace_update(decoded_key))
5628 except RuntimeError:
5629 pass
5630 return _http_json_response(
5631 {
5632 "ok": True,
5633 "work_id": work_id,
5634 "removed_shot_id": shot_id,
5635 "workplace": workplace,
5636 }
5637 )
5638
5639 def _handle_workplace_shot_split_shot(self, request: WsRequest, key: str, shot_id: int) -> Response:
5640 logger.info("workplace HTTP split-shot session_key={} shot_id={}", key, shot_id)
5641 if not self._check_api_token(request):
5642 return _http_error(401, "Unauthorized")
5643 legacy_err = self._legacy_webui_session_key_error(key)
5644 if legacy_err is not None:
5645 return legacy_err
5646 decoded_key = self._resolve_webui_api_session_key(key, request)
5647 if decoded_key is None:
5648 return _http_error(404, "session not found")
5649 split_payload = self._parse_split_shot_body(request)
5650 try:
5651 split_result = self._apply_workplace_shot_split_shot(
5652 decoded_key,
5653 shot_id,
5654 split_payload=split_payload,
5655 )
5656 except ValueError as exc:
5657 return _http_error(400, str(exc))
5658 if split_result is None:
5659 return _http_error(404, "beat not found")
5660 work_id, workplace = split_result
5661 try:
5662 loop = asyncio.get_running_loop()
5663 loop.create_task(self._publish_workplace_update(decoded_key))
5664 except RuntimeError:
5665 pass
5666 return _http_json_response(
5667 {
5668 "ok": True,
5669 "work_id": work_id,
5670 "split_shot_id": shot_id,
5671 "new_shot_id": shot_id + 1,
5672 "workplace": workplace,
5673 }
5674 )
5675
5676 def _handle_workplace_shot_revise(self, request: WsRequest, key: str, shot_id: int) -> Response:
5677 if not self._check_api_token(request):
5678 return _http_error(401, "Unauthorized")
5679 legacy_err = self._legacy_webui_session_key_error(key)
5680 if legacy_err is not None:
5681 return legacy_err
5682 decoded_key = self._resolve_webui_api_session_key(key, request)
5683 if decoded_key is None:
5684 return _http_error(404, "session not found")
5685 feedback = (_query_first(_parse_query(request.path), "feedback") or "").strip()
5686 if not feedback:
5687 return _http_error(400, "feedback required")
5688 try:
5689 reviewed = self._apply_workplace_review(decoded_key, shot_id, verdict="revise", feedback=feedback)
5690 except ValueError as exc:
5691 return _http_error(400, str(exc))
5692 if reviewed is None:
5693 return _http_error(404, "shot not found")
5694 work_id, workplace = reviewed
5695 self._schedule_workplace_revision_instruction(
5696 decoded_key,
5697 work_id=work_id,
5698 shot_id=shot_id,
5699 feedback=feedback,
5700 )
5701 try:
5702 loop = asyncio.get_running_loop()
5703 loop.create_task(self._publish_workplace_update(decoded_key))
5704 except RuntimeError:
5705 pass
5706 return _http_json_response(
5707 {
5708 "ok": True,
5709 "work_id": work_id,
5710 "shot_id": shot_id,
5711 "status": "review_fail",
5712 "feedback": feedback,
5713 "workplace": workplace,
5714 }
5715 )
5716
5717 def _resolve_workplace_request(
5718 self,
5719 request: WsRequest,
5720 key: str,
5721 ) -> tuple[str, str] | Response:
5722 if not self._check_api_token(request):
5723 return _http_error(401, "Unauthorized")
5724 legacy_err = self._legacy_webui_session_key_error(key)
5725 if legacy_err is not None:
5726 return legacy_err
5727 decoded_key = self._resolve_webui_api_session_key(key, request)
5728 if decoded_key is None:
5729 return _http_error(404, "session not found")
5730 work_id = self._resolve_work_id_for_session(decoded_key)
5731 if not work_id:
5732 return _http_error(404, "director work not found")
5733 return decoded_key, work_id
5734
5735 @staticmethod
5736 def _workplace_story_text(paths: dict[str, Path]) -> str:
5737 try:
5738 if paths["story"].exists():
5739 return paths["story"].read_text(encoding="utf-8")
5740 except OSError:
5741 return ""
5742 return ""
5743
5744 @staticmethod
5745 def _write_workplace_story_text(paths: dict[str, Path], story_md: str) -> None:
5746 paths["story"].parent.mkdir(parents=True, exist_ok=True)
5747 paths["story"].write_text(story_md, encoding="utf-8")
5748
5749 @staticmethod
5750 def _parse_confirm_story_body(request: WsRequest) -> str | None:
5751 """Return an optional ``story_md`` override from the confirm-story request.
5752
5753 The websockets HTTP surface only accepts GET without a request body, so the
5754 WebUI ships JSON ``{"story_md": "..."}`` in ``X-Nanobot-Body`` (base64).
5755 Direct unit tests may attach a ``body`` attribute to the request object.
5756 """
5757 raw_body = getattr(request, "body", None)
5758 if isinstance(raw_body, (bytes, bytearray)) and raw_body:
5759 try:
5760 payload = json.loads(raw_body)
5761 except (json.JSONDecodeError, UnicodeDecodeError):
5762 payload = None
5763 if isinstance(payload, dict):
5764 value = payload.get("story_md")
5765 if isinstance(value, str):
5766 return value
5767
5768 encoded = request.headers.get("X-Nanobot-Body")
5769 if encoded:
5770 try:
5771 payload = json.loads(base64.b64decode(encoded, validate=True))
5772 except (json.JSONDecodeError, UnicodeDecodeError, binascii.Error, ValueError):
5773 payload = None
5774 if isinstance(payload, dict):
5775 value = payload.get("story_md")
5776 if isinstance(value, str):
5777 return value
5778
5779 value = _query_first(_parse_query(request.path), "story_md")
5780 if value is not None:
5781 return value
5782 return None
5783
5784 @staticmethod
5785 def _workplace_shot_entries(state: dict[str, Any]) -> list[dict[str, Any]]:
5786 shots = state.get("shots")
5787 if not isinstance(shots, dict):
5788 return []
5789 entries = [item for item in shots.values() if isinstance(item, dict)]
5790 entries.sort(key=lambda item: int(item.get("shot_id") or 0))
5791 return entries
5792
5793 def _workplace_disk_shots(self, work_id: str) -> list[dict[str, Any]]:
5794 paths = self._workplace_paths(work_id)
5795 if paths is None:
5796 return []
5797 shots: list[dict[str, Any]] = []
5798 for shot_path in sorted(paths["shots"].glob("shot_*.json")):
5799 shot = self._read_json_file(shot_path, {})
5800 if isinstance(shot, dict):
5801 shots.append(shot)
5802 return shots
5803
5804 def _workplace_shot_specs_complete_on_disk(
5805 self,
5806 work_id: str,
5807 *,
5808 beat_count: int = 0,
5809 ) -> bool:
5810 shots = self._workplace_disk_shots(work_id)
5811 if not shots:
5812 return False
5813 if beat_count > 0 and len(shots) != beat_count:
5814 return False
5815 return all(self._shot_has_caption(shot) for shot in shots)
5816
5817 def _workplace_shot_prompts_progress(
5818 self,
5819 work_id: str,
5820 *,
5821 beat_count: int,
5822 ) -> dict[str, int] | None:
5823 if beat_count <= 0:
5824 return None
5825 ready = sum(
5826 1
5827 for shot in self._workplace_disk_shots(work_id)
5828 if self._shot_has_caption(shot)
5829 )
5830 return {"ready": ready, "total": beat_count}
5831
5832 @staticmethod
5833 def _workplace_shot_specs_complete(
5834 state: dict[str, Any],
5835 *,
5836 beat_count: int = 0,
5837 ) -> bool:
5838 shot_entries = WebSocketChannel._workplace_shot_entries(state)
5839 if not shot_entries:
5840 return False
5841 if beat_count > 0 and len(shot_entries) != beat_count:
5842 return False
5843 return all(item.get("has_shot_spec") for item in shot_entries)
5844
5845 def _workplace_shot_prompts_ready(
5846 self,
5847 work_id: str,
5848 state: dict[str, Any],
5849 *,
5850 beat_count: int = 0,
5851 ) -> bool:
5852 if not state.get("story_confirmed"):
5853 return False
5854 return self._workplace_shot_specs_complete_on_disk(
5855 work_id,
5856 beat_count=beat_count,
5857 )
5858
5859 def _sync_story_confirmed_when_shot_specs_ready(
5860 self,
5861 work_id: str,
5862 state: dict[str, Any],
5863 *,
5864 beat_count: int = 0,
5865 ) -> None:
5866 """Heal story_confirmed when shot specs exist but confirm was blocked."""
5867 if state.get("story_confirmed"):
5868 return
5869 if not self._workplace_shot_specs_complete(state, beat_count=beat_count):
5870 if not self._workplace_shot_specs_complete_on_disk(work_id, beat_count=beat_count):
5871 return
5872 state["story_confirmed"] = True
5873 self._save_workplace_state(work_id, state)
5874
5875 @staticmethod
5876 def _workplace_pending_remote_jobs(state: dict[str, Any]) -> dict[str, Any]:
5877 pending = state.get("pending_remote_jobs")
5878 return pending if isinstance(pending, dict) else {}
5879
5880 def _schedule_workplace_workflow_instruction(
5881 self,
5882 session_key: str,
5883 *,
5884 work_id: str,
5885 injected_event: str,
5886 content: str,
5887 silent: bool = True,
5888 extra_metadata: dict[str, Any] | None = None,
5889 ) -> None:
5890 metadata: dict[str, Any] = {
5891 "injected_event": injected_event,
5892 "injected_role": "user",
5893 "work_id": work_id,
5894 }
5895 if silent:
5896 metadata["silent"] = True
5897 if extra_metadata:
5898 metadata.update(extra_metadata)
5899 msg = InboundMessage(
5900 channel="system",
5901 sender_id="workplace",
5902 chat_id=session_key,
5903 content=content,
5904 session_key_override=session_key,
5905 metadata=metadata,
5906 )
5907 scheduled = self._schedule_coro(
5908 lambda: self.bus.publish_inbound(msg),
5909 warning=(
5910 "websocket: unable to schedule workplace workflow instruction "
5911 f"({injected_event})"
5912 ),
5913 )
5914 if scheduled:
5915 logger.info(
5916 "websocket: scheduled workplace workflow instruction event={} session_key={}",
5917 injected_event,
5918 session_key,
5919 )
5920
5921 def _parse_story_save_body(self, request: WsRequest) -> str | None:
5922 payload = self._parse_json_body_payload(request)
5923 if isinstance(payload, dict):
5924 value = payload.get("story_md")
5925 if isinstance(value, str):
5926 return value
5927 value = _query_first(_parse_query(request.path), "story_md")
5928 if value is not None:
5929 return value
5930 return None
5931
5932 def _parse_story_profile_save_body(self, request: WsRequest) -> dict[str, Any] | None:
5933 payload = self._parse_json_body_payload(request)
5934 if not isinstance(payload, dict):
5935 return None
5936 profile = payload.get("story_profile")
5937 if isinstance(profile, dict):
5938 return profile
5939 return payload
5940
5941 def _schedule_workplace_story_edit_instruction(
5942 self,
5943 session_key: str,
5944 *,
5945 work_id: str,
5946 ) -> None:
5947 content = (
5948 "Internal workplace workflow task. Execute silently.\n\n"
5949 f"Director work_id: `{work_id}`\n\n"
5950 "The user edited `story.md` in the workplace UI.\n"
5951 "Required actions, in order:\n"
5952 "1. Call `get_story` to read the latest `story_md` and current `story_profile`.\n"
5953 "2. Reconcile `story_profile` with the updated screenplay:\n"
5954 " - Update `summary` if the story focus changed.\n"
5955 " - Rebuild `beats` so each planned shot in `story_md` has exactly one "
5956 "`{shot_id, summary}` entry (renumber from 1, match the new shot count).\n"
5957 " - Remove beats for shots deleted from the screenplay; add beats for new shots.\n"
5958 "3. If the shot count changed, call `set_director_goal` with the new `shot_count`.\n"
5959 "4. Call `write_story` with the current `story_md` unchanged, the reconciled "
5960 "`story_profile`, and `confirmed=false`. Do not rewrite `story.md`.\n"
5961 "5. Do not call `create_shot_prompt` or `generate_echo_shot` in this turn.\n\n"
5962 "Strict constraints:\n"
5963 "- Do not ask the user any question.\n"
5964 "- Do not send any user-facing message before or after the tool calls.\n"
5965 "- Do not paste screenplay or profile JSON into chat."
5966 )
5967 self._schedule_workplace_workflow_instruction(
5968 session_key,
5969 work_id=work_id,
5970 injected_event="workplace_story_edit",
5971 content=content,
5972 )
5973
5974 def _schedule_workplace_beats_edit_instruction(
5975 self,
5976 session_key: str,
5977 *,
5978 work_id: str,
5979 ) -> None:
5980 content = (
5981 "Internal workplace workflow task. Execute silently.\n\n"
5982 f"Director work_id: `{work_id}`\n\n"
5983 "The user edited `story_profile.beats` in the workplace UI (merge, split, remove, or save).\n"
5984 "Required actions, in order:\n"
5985 "1. Call `get_story` to read the latest `story_profile` and current `story_md`.\n"
5986 "2. Call `set_director_goal` with `shot_count` equal to the number of beats.\n"
5987 "3. Call `write_story` with the current `story_md` unchanged, the updated "
5988 "`story_profile` from disk (must keep non-empty `summary` and `beats`), "
5989 "and `confirmed=true`. Do not rewrite `story.md`.\n"
5990 "4. Create exactly one `create_shot_prompt` per beat (shot_id 1..N). "
5991 "Do not create or keep any shot beyond N.\n"
5992 "5. Do not call `generate_echo_shot` in this turn.\n\n"
5993 "Strict constraints:\n"
5994 "- Do not ask the user any question.\n"
5995 "- Do not send any user-facing message."
5996 )
5997 self._schedule_workplace_workflow_instruction(
5998 session_key,
5999 work_id=work_id,
6000 injected_event="workplace_beats_edit",
6001 content=content,
6002 )
6003
6004 def _apply_workplace_story_save(
6005 self,
6006 session_key: str,
6007 story_md: str,
6008 ) -> tuple[str, dict[str, Any]]:
6009 work_id = self._resolve_work_id_for_session(session_key)
6010 if not work_id:
6011 raise ValueError("director work not found")
6012 paths = self._workplace_paths(work_id)
6013 if paths is None:
6014 raise ValueError("director work not found")
6015 state = self._load_workplace_state(work_id)
6016 self._assert_story_discussion_editable(state, field="story.md")
6017 cleaned = story_md.strip()
6018 if not cleaned:
6019 raise ValueError("story_md cannot be empty")
6020 existing = self._workplace_story_text(paths).strip()
6021 if cleaned == existing:
6022 # No changes — skip writing and agent notification.
6023 workplace = self._build_workplace_payload(session_key)
6024 return work_id, workplace
6025 try:
6026 self._write_workplace_story_text(paths, cleaned)
6027 except OSError as exc:
6028 raise ValueError(f"failed to write story: {exc}") from exc
6029 # Mark that the story was edited and agent has not yet reconciled it.
6030 state["story_pending_agent_review"] = True
6031 self._save_workplace_state(work_id, state)
6032 self._schedule_workplace_story_edit_instruction(session_key, work_id=work_id)
6033 workplace = self._build_workplace_payload(session_key)
6034 return work_id, workplace
6035
6036 def _apply_workplace_story_profile_save(
6037 self,
6038 session_key: str,
6039 incoming: dict[str, Any],
6040 ) -> tuple[str, dict[str, Any]]:
6041 work_id = self._resolve_work_id_for_session(session_key)
6042 if not work_id:
6043 raise ValueError("director work not found")
6044 paths = self._workplace_paths(work_id)
6045 if paths is None:
6046 raise ValueError("director work not found")
6047 state = self._load_workplace_state(work_id)
6048 self._assert_beats_editable(work_id, state)
6049 profile = self._load_workplace_story_profile(work_id)
6050 _profile_fields = (
6051 "summary",
6052 "beats",
6053 "characters",
6054 "genre",
6055 "setting",
6056 "title",
6057 "tone",
6058 "anchors",
6059 "shot_to_content",
6060 "content_to_shots",
6061 "language",
6062 "dialogue_language",
6063 )
6064 before_snapshot = json.dumps(
6065 {f: profile.get(f) for f in _profile_fields}, sort_keys=True
6066 )
6067 for field in _profile_fields:
6068 if field in incoming:
6069 profile[field] = incoming[field]
6070 after_snapshot = json.dumps(
6071 {f: profile.get(f) for f in _profile_fields}, sort_keys=True
6072 )
6073 if before_snapshot == after_snapshot:
6074 # No changes — skip writing and agent notification.
6075 workplace = self._build_workplace_payload(session_key)
6076 return work_id, workplace
6077 self._clear_workplace_shot_files(work_id, state)
6078 self._save_workplace_story_profile(work_id, profile, state)
6079 self._save_workplace_state(work_id, state)
6080 self._schedule_workplace_beats_edit_instruction(session_key, work_id=work_id)
6081 workplace = self._build_workplace_payload(session_key)
6082 return work_id, workplace
6083
6084 def _handle_workplace_story_save(self, request: WsRequest, key: str) -> Response:
6085 logger.info("workplace HTTP story/save session_key={}", key)
6086 resolved = self._resolve_workplace_request(request, key)
6087 if not isinstance(resolved, tuple):
6088 return resolved
6089 decoded_key, _work_id = resolved
6090 story_md = self._parse_story_save_body(request)
6091 if story_md is None:
6092 return _http_error(400, "story_md is required")
6093 try:
6094 work_id, workplace = self._apply_workplace_story_save(decoded_key, story_md)
6095 except ValueError as exc:
6096 return _http_error(400, str(exc))
6097 try:
6098 loop = asyncio.get_running_loop()
6099 loop.create_task(self._publish_workplace_update(decoded_key))
6100 except RuntimeError:
6101 pass
6102 return _http_json_response(
6103 {
6104 "ok": True,
6105 "action": "save_story",
6106 "work_id": work_id,
6107 "workplace": workplace,
6108 }
6109 )
6110
6111 def _handle_workplace_story_profile_save(self, request: WsRequest, key: str) -> Response:
6112 logger.info("workplace HTTP story-profile/save session_key={}", key)
6113 resolved = self._resolve_workplace_request(request, key)
6114 if not isinstance(resolved, tuple):
6115 return resolved
6116 decoded_key, _work_id = resolved
6117 incoming = self._parse_story_profile_save_body(request)
6118 if not isinstance(incoming, dict):
6119 return _http_error(400, "story_profile is required")
6120 try:
6121 work_id, workplace = self._apply_workplace_story_profile_save(decoded_key, incoming)
6122 except ValueError as exc:
6123 return _http_error(400, str(exc))
6124 try:
6125 loop = asyncio.get_running_loop()
6126 loop.create_task(self._publish_workplace_update(decoded_key))
6127 except RuntimeError:
6128 pass
6129 return _http_json_response(
6130 {
6131 "ok": True,
6132 "action": "save_story_profile",
6133 "work_id": work_id,
6134 "workplace": workplace,
6135 }
6136 )
6137
6138 def _resolve_workplace_session(self, request: WsRequest, key: str) -> str | Response:
6139 if not self._check_api_token(request):
6140 return _http_error(401, "Unauthorized")
6141 legacy_err = self._legacy_webui_session_key_error(key)
6142 if legacy_err is not None:
6143 return legacy_err
6144 decoded_key = self._resolve_webui_api_session_key(key, request)
6145 if decoded_key is None:
6146 return _http_error(404, "session not found")
6147 return decoded_key
6148
6149 def _reference_image_locked_for_session(self, session_key: str) -> bool:
6150 if self._session_manager is None:
6151 return False
6152 session = self._session_manager.get_or_create(session_key)
6153 metadata = session.metadata if isinstance(session.metadata, dict) else {}
6154 if is_reference_image_locked(metadata):
6155 return True
6156 work_id = self._resolve_work_id_for_session(session_key)
6157 if not work_id:
6158 return False
6159 state = self._load_workplace_state(work_id)
6160 return is_reference_image_locked(state)
6161
6162 def _parse_reference_image_save_body(self, request: WsRequest) -> dict[str, Any] | None:
6163 payload = self._parse_json_body_payload(request)
6164 query = _parse_query(request.path)
6165 merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
6166 for key in ("url", "name", "width", "height", "referenceImageUrl", "reference_image_url"):
6167 value = _query_first(query, key)
6168 if value is not None and key not in merged:
6169 merged[key] = value
6170 return normalize_reference_image(merged)
6171
6172 def _persist_reference_image(
6173 self,
6174 session_key: str,
6175 ref: dict[str, Any] | None,
6176 ) -> dict[str, Any]:
6177 if self._session_manager is None:
6178 raise ValueError("session manager unavailable")
6179 session = self._session_manager.get_or_create(session_key)
6180 if not isinstance(session.metadata, dict):
6181 session.metadata = {}
6182 if self._reference_image_locked_for_session(session_key):
6183 raise PermissionError("不可修改")
6184 previous_url = reference_image_url(session.metadata.get("reference_image"))
6185 next_url = reference_image_url(ref) if ref else ""
6186 if mark_reference_image_needs_story_rewrite(
6187 session.metadata,
6188 previous_url=previous_url,
6189 next_url=next_url,
6190 ):
6191 logger.info(
6192 "reference image changed, story rewrite required session_key={} "
6193 "previous_url={} next_url={} suppressed={}",
6194 session_key,
6195 previous_url or "-",
6196 next_url or "-",
6197 story_rewrite_suppressed(session.metadata),
6198 )
6199 if ref is None:
6200 session.metadata.pop("reference_image", None)
6201 else:
6202 session.metadata["reference_image"] = ref
6203 self._session_manager.save(session)
6204 work_id = self._resolve_work_id_for_session(session_key)
6205 if work_id:
6206 state = self._load_workplace_state(work_id)
6207 if not is_reference_image_locked(state):
6208 if ref is None:
6209 state["reference_image"] = None
6210 else:
6211 state["reference_image"] = ref
6212 self._save_workplace_state(work_id, state)
6213 return self._build_workplace_payload(session_key)
6214
6215 def _reference_image_present_for_session(self, session_key: str) -> bool:
6216 if self._session_manager is None:
6217 return False
6218 session = self._session_manager.get_or_create(session_key)
6219 metadata = session.metadata if isinstance(session.metadata, dict) else {}
6220 if reference_image_present(metadata.get("reference_image")):
6221 return True
6222 work_id = self._resolve_work_id_for_session(session_key)
6223 if not work_id:
6224 return False
6225 state = self._load_workplace_state(work_id)
6226 return reference_image_present(state.get("reference_image"))
6227
6228 def _commit_reference_image_gate(self, session_key: str, answer: str):
6229 from nanobot.session.reference_image_gate import (
6230 UploadGateResult,
6231 commit_upload_gate,
6232 decline_streak,
6233 evaluate_upload_gate,
6234 is_upload_gate_answer,
6235 )
6236
6237 if self._session_manager is None or not is_upload_gate_answer(answer):
6238 return None
6239 session = self._session_manager.get_or_create(session_key)
6240 metadata = session.metadata if isinstance(session.metadata, dict) else {}
6241 if get_auto_generate(metadata) or self._workplace_auto_generate_active(session_key):
6242 return None
6243 present = self._reference_image_present_for_session(session_key)
6244 result: UploadGateResult = evaluate_upload_gate(
6245 present=present,
6246 answer=answer,
6247 streak=decline_streak(metadata),
6248 )
6249 commit_upload_gate(metadata, result)
6250 session.metadata = metadata
6251 self._session_manager.save(session)
6252 if result.delete_image:
6253 try:
6254 self._persist_reference_image(session_key, None)
6255 except PermissionError:
6256 logger.error(
6257 "reference image gate could not delete locked image session_key={}",
6258 session_key,
6259 )
6260 except Exception:
6261 logger.opt(exception=True).error(
6262 "reference image gate delete failed session_key={}",
6263 session_key,
6264 )
6265 return result
6266
6267 async def _emit_upload_gate_mismatch_card(
6268 self,
6269 session_key: str,
6270 chat_id: str,
6271 question: str,
6272 ) -> None:
6273 from nanobot.agent.tools.ask_user import normalize_question_cards
6274 from nanobot.session.question_cards import build_ask_user_session_messages
6275 from nanobot.session.reference_image_gate import mismatch_card
6276
6277 if self._session_manager is None:
6278 return
6279 cards = normalize_question_cards([mismatch_card(question)])
6280 if isinstance(cards, str):
6281 logger.error(
6282 "reference image gate card invalid session_key={} error={}",
6283 session_key,
6284 cards,
6285 )
6286 return
6287 batch_id = str(uuid.uuid4())
6288 tool_call_id = f"call_{batch_id}"
6289 session = self._session_manager.get_or_create(session_key)
6290 session.messages.extend(
6291 build_ask_user_session_messages(
6292 tool_call_id=tool_call_id,
6293 content=question,
6294 questions=cards,
6295 batch_id=batch_id,
6296 channel="websocket",
6297 chat_id=chat_id,
6298 )
6299 )
6300 session.updated_at = datetime.now()
6301 self._session_manager.save(session)
6302 await self.send(
6303 OutboundMessage(
6304 channel="websocket",
6305 chat_id=chat_id,
6306 content="",
6307 metadata={
6308 "questions": cards,
6309 "question_batch_id": batch_id,
6310 "session_key": session_key,
6311 },
6312 )
6313 )
6314 logger.info(
6315 "reference image gate mismatch card session_key={} question={}",
6316 session_key,
6317 question,
6318 )
6319
6320 def _persist_gate_user_reply(self, session_key: str, content: str) -> None:
6321 if self._session_manager is None:
6322 return
6323 session = self._session_manager.get_or_create(session_key)
6324 session.messages.append(
6325 {
6326 "role": "user",
6327 "content": content,
6328 "timestamp": datetime.now().isoformat(),
6329 }
6330 )
6331 session.updated_at = datetime.now()
6332 self._session_manager.save(session)
6333
6334 def _handle_workplace_reference_image_save(self, request: WsRequest, key: str) -> Response:
6335 logger.info("workplace HTTP reference-image/save session_key={}", key)
6336 resolved = self._resolve_workplace_session(request, key)
6337 if not isinstance(resolved, str):
6338 return resolved
6339 ref = self._parse_reference_image_save_body(request)
6340 if ref is None:
6341 return _http_error(400, "url is required")
6342 url = str(ref.get("url") or "")
6343 if is_blocked_local_url(url):
6344 logger.error(
6345 "workplace reference-image/save rejected local url session_key={}",
6346 resolved,
6347 )
6348 return _http_error(400, "url must be a public HTTP(S) address")
6349 try:
6350 configure_download_policy(self._download_url_policy())
6351 validate_external_url(url)
6352 except UrlValidationError as exc:
6353 logger.error(
6354 "workplace reference-image/save invalid url session_key={} error={}",
6355 resolved,
6356 exc,
6357 )
6358 return _http_error(400, str(exc))
6359 try:
6360 workplace = self._persist_reference_image(resolved, ref)
6361 except PermissionError:
6362 return _http_error(409, "不可修改")
6363 except ValueError as exc:
6364 return _http_error(400, str(exc))
6365 try:
6366 loop = asyncio.get_running_loop()
6367 loop.create_task(self._publish_workplace_update(resolved))
6368 except RuntimeError:
6369 pass
6370 return _http_json_response(
6371 {
6372 "ok": True,
6373 "action": "save_reference_image",
6374 "workplace": workplace,
6375 }
6376 )
6377
6378 def _handle_workplace_reference_image_delete(self, request: WsRequest, key: str) -> Response:
6379 logger.info("workplace HTTP reference-image/delete session_key={}", key)
6380 resolved = self._resolve_workplace_session(request, key)
6381 if not isinstance(resolved, str):
6382 return resolved
6383 try:
6384 workplace = self._persist_reference_image(resolved, None)
6385 except PermissionError:
6386 return _http_error(409, "不可修改")
6387 except ValueError as exc:
6388 return _http_error(400, str(exc))
6389 try:
6390 loop = asyncio.get_running_loop()
6391 loop.create_task(self._publish_workplace_update(resolved))
6392 except RuntimeError:
6393 pass
6394 return _http_json_response(
6395 {
6396 "ok": True,
6397 "action": "delete_reference_image",
6398 "workplace": workplace,
6399 }
6400 )
6401
6402 def _schedule_workplace_confirm_story_instruction(
6403 self,
6404 session_key: str,
6405 *,
6406 work_id: str,
6407 ) -> None:
6408 content = (
6409 "Internal workplace workflow task. Execute silently.\n\n"
6410 f"Director work_id: `{work_id}`\n\n"
6411 "Required actions, in order:\n"
6412 "1. Call `get_story` and `get_workplace_status(include_shots=true)`.\n"
6413 "2. If `story.md` is missing or empty, stop after tool inspection only.\n"
6414 "3. Ensure required goal fields, especially `shot_count`, are written with `set_director_goal` when missing.\n"
6415 "4. If `story_profile` is missing, incomplete, or has empty `beats`, derive a valid "
6416 "`story_profile` (non-empty `summary` plus one beat per planned shot) from `story_md` "
6417 "before confirming.\n"
6418 "5. Call `write_story` with the current `story_md`, the validated `story_profile`, "
6419 "and `confirmed=true`.\n"
6420 "6. Do not call `create_shot_prompt` or `generate_echo_shot` in this turn.\n\n"
6421 "Strict constraints:\n"
6422 "- Do not ask the user any question.\n"
6423 "- Do not acknowledge, summarize, or explain progress to the user.\n"
6424 "- Do not send any user-facing message before or after the tool calls.\n"
6425 "- Do not paste screenplay or shot specs into chat."
6426 )
6427 self._schedule_workplace_workflow_instruction(
6428 session_key,
6429 work_id=work_id,
6430 injected_event="workplace_workflow_confirm_story",
6431 content=content,
6432 )
6433
6434 def _schedule_workplace_start_merge_instruction(
6435 self,
6436 session_key: str,
6437 *,
6438 work_id: str,
6439 ) -> None:
6440 content = (
6441 "Internal workplace workflow task. Execute silently.\n\n"
6442 f"Director work_id: `{work_id}`\n\n"
6443 "Required actions, in order:\n"
6444 "1. Call `get_workplace_status(include_shots=true)`.\n"
6445 "2. Call `merge_shot` with every approved shot ID in timeline order.\n\n"
6446 "Strict constraints:\n"
6447 "- Do not ask the user any question.\n"
6448 "- Do not acknowledge, summarize, or explain progress to the user.\n"
6449 "- Do not send any user-facing message before or after the tool calls.\n"
6450 "- The merge tool call is mandatory once all shots are approved."
6451 )
6452 self._schedule_workplace_workflow_instruction(
6453 session_key,
6454 work_id=work_id,
6455 injected_event="workplace_workflow_start_merge",
6456 content=content,
6457 )
6458
6459 def _schedule_workplace_start_generation_instruction(
6460 self,
6461 session_key: str,
6462 *,
6463 work_id: str,
6464 ) -> None:
6465 content = (
6466 "Internal workplace workflow task. Execute silently.\n\n"
6467 f"Director work_id: `{work_id}`\n\n"
6468 "Required actions, in order:\n"
6469 "1. Call `get_workplace_status(include_shots=true)` and `get_story`.\n"
6470 "2. If the screenplay is not confirmed yet, call `write_story(..., confirmed=true)` first.\n"
6471 "3. Call `set_director_goal(generation_mode=\"sequential\")` unless generation mode is already set.\n"
6472 "4. For each outer shot from 1 to the locked `goal.shot_count`, create or update shot prompts with "
6473 "`create_shot_prompt` when the caption is missing. Use `goal.shot_count` from status, not "
6474 "session duration defaults.\n"
6475 "5. Follow `get_guidance(topic=\"shot-prompt-writer\")` while writing shot captions.\n"
6476 "6. For every shot that has a caption and is not already queued, generated, or approved, call `get_shot` when needed.\n"
6477 "7. Decide `reference_shot_ids` for each shot.\n"
6478 "8. Call `set_shot_references` for each ready shot with the chosen references and a short selection note.\n\n"
6479 "Strict constraints:\n"
6480 "- Outer film length is the locked `goal.shot_count`. Create exactly that many `create_shot_prompt` calls "
6481 "(shot_id=1..shot_count). If the user locked 4 shots, write 4 captions.\n"
6482 "- Caption prefix `本视频包含N个镜头` / `This video has N shots` is INTERNAL segments of THIS 10s clip only. "
6483 "N must be 2 or 3 (prefer 3). Never set N to the outer shot_count. Never describe the whole film as 3 shots "
6484 "when outer shot_count is 4.\n"
6485 "- Do not call `generate_echo_shot` in this turn.\n"
6486 "- Do not ask the user any question.\n"
6487 "- Do not acknowledge, summarize, or explain progress to the user.\n"
6488 "- Do not send any user-facing message before or after the tool calls.\n"
6489 "- Every ready shot must receive a `set_shot_references` call before you stop."
6490 )
6491 self._schedule_workplace_workflow_instruction(
6492 session_key,
6493 work_id=work_id,
6494 injected_event="workplace_workflow_start_generation",
6495 content=content,
6496 )
6497
6498 def _apply_workplace_start_generation(
6499 self,
6500 session_key: str,
6501 ) -> tuple[str, dict[str, Any]]:
6502 work_id = self._resolve_work_id_for_session(session_key)
6503 if not work_id:
6504 raise ValueError("director work not found")
6505 paths = self._workplace_paths(work_id)
6506 if paths is None:
6507 raise ValueError("director work not found")
6508 state = self._read_json_file(paths["state"], {})
6509 if not isinstance(state, dict):
6510 state = {}
6511 self._reconcile_workplace_state_shots(work_id, state)
6512 if state.get("final_output_url") or state.get("final_output_path"):
6513 raise ValueError("work already completed")
6514 if not state.get("story_confirmed") and not self._try_sync_workplace_story_confirmed(
6515 work_id,
6516 paths,
6517 state,
6518 ):
6519 raise ValueError("story is not confirmed; confirm the screenplay first")
6520 profile = self._load_workplace_story_profile(work_id)
6521 beats = profile.get("beats") if isinstance(profile.get("beats"), list) else []
6522 beat_count = len(beats)
6523 if beat_count > 0:
6524 self._prune_workplace_shots_beyond_count(work_id, beat_count, state)
6525 self._reconcile_workplace_state_shots(work_id, state)
6526 self._sync_story_confirmed_when_shot_specs_ready(
6527 work_id,
6528 state,
6529 beat_count=beat_count,
6530 )
6531 if beat_count <= 0:
6532 raise ValueError("story_profile.beats is empty; confirm the screenplay first")
6533 pending_kinds = {
6534 str(item.get("kind"))
6535 for item in self._workplace_pending_remote_jobs(state).values()
6536 if isinstance(item, dict)
6537 }
6538 if "generate_echo_shot" in pending_kinds:
6539 raise ValueError("shot generation already in progress")
6540 if pending_kinds.intersection({"merge_shot"}):
6541 raise ValueError("merge already in progress")
6542 stage = str(state.get("stage") or "")
6543 if stage not in {"merging", "done"}:
6544 state["stage"] = "shot_generating"
6545 state["shot_generating_started_at"] = (
6546 datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
6547 )
6548 self._save_workplace_state(work_id, state)
6549 specs_complete = self._workplace_shot_specs_complete_on_disk(work_id, beat_count=beat_count)
6550 self._ensure_workplace_shot_references(work_id)
6551 pending_review = bool(state.get("story_pending_agent_review"))
6552 has_prior_video = self._workplace_has_generated_video(work_id, state)
6553 # Fast-path only when specs exist and no replan / prior-generation residue remains.
6554 if not specs_complete or pending_review or has_prior_video:
6555 self._schedule_workplace_start_generation_instruction(session_key, work_id=work_id)
6556 workplace = self._build_workplace_payload(session_key)
6557 return work_id, workplace
6558
6559 def _schedule_workplace_abort_generation_stop(self, session_key: str) -> None:
6560 """Cancel a hung start-generation agent turn without a user-facing chat reply."""
6561 msg = InboundMessage(
6562 channel="system",
6563 sender_id="workplace",
6564 chat_id=session_key,
6565 content="/stop",
6566 session_key_override=session_key,
6567 metadata={"silent": True},
6568 )
6569 scheduled = self._schedule_coro(
6570 lambda: self.bus.publish_inbound(msg),
6571 warning=(
6572 "websocket: unable to schedule abort-generation /stop "
6573 f"session_key={session_key}"
6574 ),
6575 )
6576 if scheduled:
6577 logger.info(
6578 "websocket: scheduled abort-generation /stop session_key={}",
6579 session_key,
6580 )
6581
6582 def _apply_workplace_abort_generation(
6583 self,
6584 session_key: str,
6585 ) -> tuple[str, dict[str, Any]]:
6586 """Roll shot_generating back to shot_planning so the user can retry start-generation.
6587
6588 Used when agent prep after start-generation hangs (e.g. model timeout). Not for
6589 mid-Echo generation — pending generate jobs block this path.
6590 """
6591 work_id = self._resolve_work_id_for_session(session_key)
6592 if not work_id:
6593 raise ValueError("director work not found")
6594 paths = self._workplace_paths(work_id)
6595 if paths is None:
6596 raise ValueError("director work not found")
6597 state = self._read_json_file(paths["state"], {})
6598 if not isinstance(state, dict):
6599 state = {}
6600 stage = str(state.get("stage") or "")
6601 if stage != "shot_generating":
6602 raise ValueError("abort-generation is only available during shot_generating")
6603 pending_kinds = {
6604 str(item.get("kind"))
6605 for item in self._workplace_pending_remote_jobs(state).values()
6606 if isinstance(item, dict)
6607 }
6608 if pending_kinds.intersection({"generate_echo_shot", "merge_shot"}):
6609 raise ValueError("shot generation or merge already in progress")
6610 state["stage"] = "shot_planning"
6611 state.pop("shot_generating_started_at", None)
6612 self._save_workplace_state(work_id, state)
6613 self._schedule_workplace_abort_generation_stop(session_key)
6614 workplace = self._build_workplace_payload(session_key)
6615 return work_id, workplace
6616
6617 def _apply_workplace_confirm_story(
6618 self,
6619 session_key: str,
6620 *,
6621 story_md: str | None = None,
6622 ) -> tuple[str, dict[str, Any]]:
6623 work_id = self._resolve_work_id_for_session(session_key)
6624 if not work_id:
6625 raise ValueError("director work not found")
6626 paths = self._workplace_paths(work_id)
6627 if paths is None:
6628 raise ValueError("director work not found")
6629 existing_story = self._workplace_story_text(paths).strip()
6630 story_changed = False
6631 if story_md is not None:
6632 cleaned = story_md.strip()
6633 if not cleaned:
6634 raise ValueError("story_md cannot be empty")
6635 if cleaned != existing_story:
6636 try:
6637 self._write_workplace_story_text(paths, cleaned)
6638 except OSError as exc:
6639 raise ValueError(f"failed to write story: {exc}") from exc
6640 story_changed = True
6641 current_story = self._workplace_story_text(paths).strip()
6642 if not current_story:
6643 raise ValueError("story is empty; write a screenplay before confirming")
6644 state = self._read_json_file(paths["state"], {})
6645 if not isinstance(state, dict):
6646 state = {}
6647 if state.get("final_output_url") or state.get("final_output_path"):
6648 raise ValueError("work already completed")
6649 pending_kinds = {
6650 str(item.get("kind"))
6651 for item in self._workplace_pending_remote_jobs(state).values()
6652 if isinstance(item, dict)
6653 }
6654 if pending_kinds.intersection({"generate_echo_shot", "merge_shot"}):
6655 raise ValueError("generation or merge already in progress")
6656 # Fast-path: if story was not changed *in this request*, the profile is
6657 # already valid, and no prior save is waiting for agent reconciliation,
6658 # confirm directly without dispatching an agent turn.
6659 pending_review = bool(state.get("story_pending_agent_review"))
6660 if not story_changed and not pending_review:
6661 profile = self._load_workplace_story_profile(work_id)
6662 if not _story_profile_validation_error(profile):
6663 state["story_confirmed"] = True
6664 if str(state.get("stage") or "") not in {
6665 "shot_planning",
6666 "shot_generating",
6667 "merging",
6668 "done",
6669 }:
6670 if locked_shot_count_from_state(state):
6671 state["stage"] = "shot_planning"
6672 else:
6673 state["stage"] = "story_confirmed"
6674 self._save_workplace_state(work_id, state)
6675 workplace = self._build_workplace_payload(session_key)
6676 return work_id, workplace
6677 self._schedule_workplace_confirm_story_instruction(session_key, work_id=work_id)
6678 workplace = self._build_workplace_payload(session_key)
6679 return work_id, workplace
6680
6681 def _handle_workplace_workflow_confirm_story(self, request: WsRequest, key: str) -> Response:
6682 resolved = self._resolve_workplace_request(request, key)
6683 if not isinstance(resolved, tuple):
6684 return resolved
6685 decoded_key, _work_id = resolved
6686 override_md = self._parse_confirm_story_body(request)
6687 try:
6688 work_id, workplace = self._apply_workplace_confirm_story(
6689 decoded_key,
6690 story_md=override_md,
6691 )
6692 except ValueError as exc:
6693 return _http_error(400, str(exc))
6694 try:
6695 loop = asyncio.get_running_loop()
6696 loop.create_task(self._publish_workplace_update(decoded_key))
6697 except RuntimeError:
6698 pass
6699 return _http_json_response(
6700 {
6701 "ok": True,
6702 "action": "confirm_story",
6703 "work_id": work_id,
6704 "scheduled": True,
6705 "workplace": workplace,
6706 }
6707 )
6708
6709 def _handle_workplace_workflow_abort_generation(self, request: WsRequest, key: str) -> Response:
6710 logger.info("workplace HTTP workflow/abort-generation session_key={}", key)
6711 resolved = self._resolve_workplace_request(request, key)
6712 if not isinstance(resolved, tuple):
6713 return resolved
6714 decoded_key, _work_id = resolved
6715 try:
6716 work_id, workplace = self._apply_workplace_abort_generation(decoded_key)
6717 except ValueError as exc:
6718 status = 409 if "already" in str(exc).lower() or "only available" in str(exc).lower() else 400
6719 return _http_error(status, str(exc))
6720 try:
6721 loop = asyncio.get_running_loop()
6722 loop.create_task(self._publish_workplace_update(decoded_key))
6723 except RuntimeError:
6724 pass
6725 return _http_json_response(
6726 {
6727 "ok": True,
6728 "action": "abort_generation",
6729 "work_id": work_id,
6730 "workplace": workplace,
6731 }
6732 )
6733
6734 def _handle_workplace_workflow_start_generation(self, request: WsRequest, key: str) -> Response:
6735 logger.info("workplace HTTP workflow/start-generation session_key={}", key)
6736 resolved = self._resolve_workplace_request(request, key)
6737 if not isinstance(resolved, tuple):
6738 return resolved
6739 decoded_key, _work_id = resolved
6740 try:
6741 work_id, workplace = self._apply_workplace_start_generation(decoded_key)
6742 except ValueError as exc:
6743 status = 409 if "already" in str(exc).lower() else 400
6744 return _http_error(status, str(exc))
6745 try:
6746 loop = asyncio.get_running_loop()
6747 loop.create_task(self._publish_workplace_update(decoded_key))
6748 except RuntimeError:
6749 pass
6750 return _http_json_response(
6751 {
6752 "ok": True,
6753 "action": "prepare_generation",
6754 "work_id": work_id,
6755 "scheduled": True,
6756 "workplace": workplace,
6757 }
6758 )
6759
6760 def _handle_workplace_shot_duration(self, request: WsRequest, key: str, shot_id: int) -> Response:
6761 logger.info("workplace HTTP shot duration session_key={} shot_id={}", key, shot_id)
6762 if not self._check_api_token(request):
6763 return _http_error(401, "Unauthorized")
6764 legacy_err = self._legacy_webui_session_key_error(key)
6765 if legacy_err is not None:
6766 return legacy_err
6767 decoded_key = self._resolve_webui_api_session_key(key, request)
6768 if decoded_key is None:
6769 return _http_error(404, "session not found")
6770 duration_sec = self._parse_shot_duration_body(request)
6771 if duration_sec is None:
6772 return _http_error(400, "duration_sec is required")
6773 try:
6774 work_id, workplace = self._apply_workplace_shot_duration(
6775 decoded_key,
6776 shot_id,
6777 duration_sec,
6778 )
6779 except ValueError as exc:
6780 return _http_error(400, str(exc))
6781 try:
6782 loop = asyncio.get_running_loop()
6783 loop.create_task(self._publish_workplace_update(decoded_key))
6784 except RuntimeError:
6785 pass
6786 return _http_json_response(
6787 {
6788 "ok": True,
6789 "work_id": work_id,
6790 "shot_id": shot_id,
6791 "duration_sec": self._clamp_shot_duration_sec(duration_sec),
6792 "workplace": workplace,
6793 }
6794 )
6795
6796 async def _handle_workplace_shot_generate(
6797 self, request: WsRequest, key: str, shot_id: int
6798 ) -> Response:
6799 logger.info("workplace HTTP shot generate session_key={} shot_id={}", key, shot_id)
6800 if not self._check_api_token(request):
6801 return _http_error(401, "Unauthorized")
6802 legacy_err = self._legacy_webui_session_key_error(key)
6803 if legacy_err is not None:
6804 return legacy_err
6805 decoded_key = self._resolve_webui_api_session_key(key, request)
6806 if decoded_key is None:
6807 return _http_error(404, "session not found")
6808 body = self._parse_json_body_payload(request)
6809 if isinstance(body, dict) and any(
6810 body.get(key)
6811 for key in (
6812 "reference_image_url",
6813 "referenceImageUrl",
6814 "reference_image_name",
6815 "referenceImageName",
6816 )
6817 ):
6818 logger.info(
6819 "workplace shot generate: ignoring request-body reference_image_* "
6820 "session_key={} shot_id={}",
6821 decoded_key,
6822 shot_id,
6823 )
6824 first_frame_url = None
6825 work_id = self._resolve_work_id_for_session(decoded_key)
6826 if shot_id == 1 and work_id:
6827 first_frame_url = self._workplace_first_frame_url(work_id)
6828 try:
6829 if first_frame_url and shot_id == 1:
6830 if self._provider is None or not self._model:
6831 return _http_error(503, "re-caption model unavailable")
6832 work_id, workplace = self._prepare_workplace_shot_generate_async(
6833 decoded_key,
6834 shot_id,
6835 )
6836 try:
6837 loop = asyncio.get_running_loop()
6838 loop.create_task(
6839 self._complete_workplace_shot_generate_with_reference(
6840 decoded_key,
6841 work_id=work_id,
6842 shot_id=shot_id,
6843 reference_image_url=first_frame_url,
6844 )
6845 )
6846 loop.create_task(self._publish_workplace_update(decoded_key))
6847 except RuntimeError:
6848 return _http_error(503, "event loop unavailable")
6849 return _http_json_response(
6850 {
6851 "ok": True,
6852 "action": "generate_shot",
6853 "work_id": work_id,
6854 "shot_id": shot_id,
6855 "status": "recaptioning",
6856 "workplace": workplace,
6857 }
6858 )
6859 work_id, workplace = await asyncio.to_thread(
6860 self._apply_workplace_shot_generate,
6861 decoded_key,
6862 shot_id,
6863 )
6864 except (EchoGeneratorBusyError, EchoGeneratorUnavailableError) as exc:
6865 return self._http_echo_gate_error(decoded_key, exc, shot_id=shot_id)
6866 except ValueError as exc:
6867 status = 409 if "already" in str(exc).lower() else 400
6868 return _http_error(status, str(exc))
6869 try:
6870 loop = asyncio.get_running_loop()
6871 loop.create_task(self._publish_workplace_update(decoded_key))
6872 except RuntimeError:
6873 pass
6874 return _http_json_response(
6875 {
6876 "ok": True,
6877 "action": "generate_shot",
6878 "work_id": work_id,
6879 "shot_id": shot_id,
6880 "workplace": workplace,
6881 }
6882 )
6883
6884 def _apply_workplace_shot_continuous_mode(
6885 self,
6886 session_key: str,
6887 shot_id: int,
6888 enabled: bool,
6889 ) -> tuple[str, dict[str, Any]]:
6890 loaded = self._load_workplace_shot(session_key, shot_id)
6891 if loaded is None:
6892 raise ValueError("shot not found")
6893 work_id, shot_path, state, shot = loaded
6894 if shot_id <= 1 and enabled:
6895 raise ValueError("shot 1 cannot enable continuous mode")
6896 shot["continuous_enabled"] = bool(enabled)
6897 logger.info(
6898 "shot continuous_enabled set: work_id={} shot_id={} enabled={}",
6899 work_id,
6900 shot_id,
6901 enabled,
6902 )
6903 self._save_workplace_shot(shot_path, shot)
6904 self._sync_state_shot_row(state, shot)
6905
6906 # Propagate the default to all subsequent shots so the user doesn't
6907 # have to toggle every shot manually. Each shot's own toggle stays
6908 # independent and can still be changed afterwards.
6909 next_id = shot_id + 1
6910 while True:
6911 next_loaded = self._load_workplace_shot(session_key, next_id)
6912 if next_loaded is None:
6913 break
6914 _nw, next_path, _ns, next_shot = next_loaded
6915 next_shot["continuous_enabled"] = bool(enabled)
6916 logger.info(
6917 "shot continuous_enabled cascaded: work_id={} shot_id={} enabled={}",
6918 work_id,
6919 next_id,
6920 enabled,
6921 )
6922 self._save_workplace_shot(next_path, next_shot)
6923 self._sync_state_shot_row(state, next_shot)
6924 next_id += 1
6925
6926 self._save_workplace_state(work_id, state)
6927 return work_id, self._build_workplace_payload(session_key)
6928
6929 def _handle_workplace_shot_continuous_mode(
6930 self, request: WsRequest, key: str, shot_id: int
6931 ) -> Response:
6932 logger.info(
6933 "workplace HTTP shot continuous-mode session_key={} shot_id={}", key, shot_id
6934 )
6935 if not self._check_api_token(request):
6936 return _http_error(401, "Unauthorized")
6937 legacy_err = self._legacy_webui_session_key_error(key)
6938 if legacy_err is not None:
6939 return legacy_err
6940 decoded_key = self._resolve_webui_api_session_key(key, request)
6941 if decoded_key is None:
6942 return _http_error(404, "session not found")
6943 enabled_raw = str(
6944 _query_first(_parse_query(request.path), "enabled") or ""
6945 ).lower()
6946 if enabled_raw not in {"0", "1", "true", "false", "yes", "no", "on", "off"}:
6947 return _http_error(400, "enabled is required")
6948 enabled = enabled_raw in {"1", "true", "yes", "on"}
6949 try:
6950 work_id, workplace = self._apply_workplace_shot_continuous_mode(
6951 decoded_key,
6952 shot_id,
6953 enabled,
6954 )
6955 except ValueError as exc:
6956 return _http_error(400, str(exc))
6957 try:
6958 loop = asyncio.get_running_loop()
6959 loop.create_task(self._publish_workplace_update(decoded_key))
6960 except RuntimeError:
6961 pass
6962 return _http_json_response(
6963 {
6964 "ok": True,
6965 "action": "continuous_mode",
6966 "work_id": work_id,
6967 "shot_id": shot_id,
6968 "enabled": enabled,
6969 "workplace": workplace,
6970 }
6971 )
6972
6973 def _handle_workplace_shot_continuous_generate(
6974 self, request: WsRequest, key: str, shot_id: int
6975 ) -> Response:
6976 logger.info(
6977 "workplace HTTP shot continuous-generate session_key={} shot_id={}", key, shot_id
6978 )
6979 if not self._check_api_token(request):
6980 return _http_error(401, "Unauthorized")
6981 legacy_err = self._legacy_webui_session_key_error(key)
6982 if legacy_err is not None:
6983 return legacy_err
6984 decoded_key = self._resolve_webui_api_session_key(key, request)
6985 if decoded_key is None:
6986 return _http_error(404, "session not found")
6987 try:
6988 (
6989 work_id,
6990 workplace,
6991 previous_shot_id,
6992 video_url,
6993 reference_shot_ids,
6994 selection_note,
6995 ) = self._prepare_workplace_shot_continuous_generate(
6996 decoded_key, shot_id
6997 )
6998 except (EchoGeneratorBusyError, EchoGeneratorUnavailableError) as exc:
6999 return self._http_echo_gate_error(decoded_key, exc, shot_id=shot_id)
7000 except ValueError as exc:
7001 status = 409 if "already" in str(exc).lower() else 400
7002 return _http_error(status, str(exc))
7003 try:
7004 loop = asyncio.get_running_loop()
7005 loop.create_task(
7006 self._complete_workplace_shot_continuous_generate(
7007 decoded_key,
7008 work_id=work_id,
7009 shot_id=shot_id,
7010 previous_shot_id=previous_shot_id,
7011 video_url=video_url,
7012 reference_shot_ids=reference_shot_ids,
7013 selection_note=selection_note,
7014 )
7015 )
7016 loop.create_task(self._publish_workplace_update(decoded_key))
7017 except RuntimeError:
7018 return _http_error(503, "event loop unavailable")
7019 return _http_json_response(
7020 {
7021 "ok": True,
7022 "action": "continuous_generate",
7023 "work_id": work_id,
7024 "shot_id": shot_id,
7025 "status": "i2v_preparing",
7026 "workplace": workplace,
7027 }
7028 )
7029
7030 def _prepare_workplace_shot_continuous_generate(
7031 self,
7032 session_key: str,
7033 shot_id: int,
7034 ) -> tuple[str, dict[str, Any], int, str, list[int], str | None]:
7035 """Validate a continuation and mark it queued before async VLM work."""
7036 self._ensure_echo_admission(operation="generate_echo_shot")
7037 if shot_id <= 1:
7038 raise ValueError("continuous generation is only available for shot_id > 1")
7039
7040 loaded = self._load_workplace_shot(session_key, shot_id)
7041 if loaded is None:
7042 raise ValueError("shot not found")
7043 work_id, shot_path, _state, shot = loaded
7044 if not shot.get("continuous_enabled"):
7045 raise ValueError(
7046 f"shot {shot_id} does not have continuous mode enabled; "
7047 "set continuous_enabled first via /continuous-mode"
7048 )
7049 self._lock_workplace_video_size(session_key, work_id)
7050
7051 status = str(shot.get("status") or "")
7052 if status == "queued":
7053 raise ValueError(f"shot {shot_id} generation already in progress")
7054 if status in {"generated", "review_pass", "approved"}:
7055 raise ValueError(f"shot {shot_id} is already generated")
7056
7057 previous_shot_id = shot_id - 1
7058 prev_loaded = self._load_workplace_shot(session_key, previous_shot_id)
7059 if prev_loaded is None:
7060 raise ValueError(f"previous shot {previous_shot_id} not found")
7061 _prev_work_id, _prev_path, _prev_state, prev_shot = prev_loaded
7062 prev_status = str(prev_shot.get("status") or "")
7063 if prev_status not in {"generated", "review_pass", "approved"}:
7064 raise ValueError(
7065 f"previous shot {previous_shot_id} must be generated first "
7066 f"(current: {prev_status})"
7067 )
7068 video_url = prev_shot.get("artifact_url") or (
7069 prev_shot.get("echo") if isinstance(prev_shot.get("echo"), dict) else {}
7070 ).get("result_url")
7071 if not isinstance(video_url, str) or not video_url.strip():
7072 raise ValueError(
7073 f"previous shot {previous_shot_id} has no artifact_url"
7074 )
7075
7076 self._ensure_workplace_shot_references(work_id)
7077 if "planned_reference_shot_ids" not in shot:
7078 shot = self._read_json_file(shot_path, {})
7079 if "planned_reference_shot_ids" not in shot:
7080 raise ValueError(
7081 "reference plan is not ready; complete the previous workflow step first"
7082 )
7083 reference_shot_ids = self._planned_reference_shot_ids(shot)
7084 if previous_shot_id not in reference_shot_ids:
7085 reference_shot_ids = sorted(set(reference_shot_ids) | {previous_shot_id})
7086 missing = self._missing_reference_generations(work_id, reference_shot_ids)
7087 if missing:
7088 raise ValueError(self._format_reference_dependency_error(shot_id, missing))
7089
7090 selection_note = shot.get("reference_selection_note")
7091 note = selection_note if isinstance(selection_note, str) else None
7092 shot["status"] = "queued"
7093 shot.pop("generation_error", None)
7094 self._save_workplace_shot(shot_path, shot)
7095 state = self._load_workplace_state(work_id)
7096 state["stage"] = "shot_generating"
7097 self._sync_state_shot_row(state, shot)
7098 self._save_workplace_state(work_id, state)
7099 return (
7100 work_id,
7101 self._build_workplace_payload(session_key),
7102 previous_shot_id,
7103 video_url.strip(),
7104 reference_shot_ids,
7105 note,
7106 )
7107
7108 async def _complete_workplace_shot_continuous_generate(
7109 self,
7110 session_key: str,
7111 *,
7112 work_id: str,
7113 shot_id: int,
7114 previous_shot_id: int,
7115 video_url: str,
7116 reference_shot_ids: list[int],
7117 selection_note: str | None,
7118 ) -> None:
7119 """Extract the tail, rewrite the caption with the image, then submit I2V."""
7120 try:
7121 condition_image_url = await asyncio.to_thread(
7122 self._extract_and_publish_tail_frame,
7123 work_id,
7124 previous_shot_id,
7125 video_url,
7126 )
7127 previous_loaded = self._load_workplace_shot(session_key, previous_shot_id)
7128 if previous_loaded is not None:
7129 _prev_work_id, prev_path, _prev_state, previous_shot = previous_loaded
7130 previous_shot["tail_frame_url"] = condition_image_url
7131 self._save_workplace_shot(prev_path, previous_shot)
7132
7133 if not condition_image_url:
7134 raise ValueError(
7135 f"failed to extract tail frame for shot {previous_shot_id}"
7136 )
7137
7138 loaded = self._load_workplace_shot(session_key, shot_id)
7139 if loaded is None:
7140 raise ValueError("shot not found")
7141 _work_id, _shot_path, _state, shot = loaded
7142 original_caption = str(shot.get("caption") or "").strip()
7143 if not original_caption:
7144 raise ValueError(f"shot {shot_id} has no caption")
7145 story_profile = self._load_workplace_story_profile(work_id)
7146 rewritten_prompt = await self._rewrite_i2v_prompt_with_image(
7147 original_caption,
7148 condition_image_url,
7149 story_profile,
7150 )
7151
7152 generate_tool = self._director_generate_tool()
7153 generate_tool.set_context(
7154 "websocket",
7155 webui_wire_chat_id(session_key) or "direct",
7156 effective_key=session_key,
7157 )
7158 await asyncio.to_thread(
7159 generate_tool.apply_generate_continuous,
7160 work_id,
7161 shot_id,
7162 condition_image_url,
7163 reference_shot_ids,
7164 selection_note=selection_note,
7165 i2v_prompt=rewritten_prompt,
7166 )
7167 logger.info(
7168 "workplace I2V continuation submitted work_id={} shot_id={} previous_shot_id={}",
7169 work_id,
7170 shot_id,
7171 previous_shot_id,
7172 )
7173 except Exception as exc:
7174 logger.opt(exception=True).error(
7175 "workplace I2V continuation failed work_id={} shot_id={}",
7176 work_id,
7177 shot_id,
7178 )
7179 try:
7180 paths = self._workplace_paths(work_id)
7181 if paths is not None:
7182 failed = self._read_json_file(
7183 paths["shots"] / f"shot_{shot_id:03d}.json", {}
7184 )
7185 if isinstance(failed, dict):
7186 failed["status"] = "error"
7187 failed["generation_error"] = str(exc)[:1000]
7188 self._save_workplace_shot(
7189 paths["shots"] / f"shot_{shot_id:03d}.json",
7190 failed,
7191 )
7192 state = self._load_workplace_state(work_id)
7193 state["stage"] = "shot_revising"
7194 state["generation_error"] = str(exc)[:1000]
7195 self._save_workplace_state(work_id, state)
7196 except Exception:
7197 logger.opt(exception=True).error(
7198 "failed to persist workplace I2V error work_id={} shot_id={}",
7199 work_id,
7200 )
7201 finally:
7202 try:
7203 await self._publish_workplace_update(session_key)
7204 except Exception:
7205 logger.opt(exception=True).error(
7206 "workplace I2V update failed work_id={} shot_id={}",
7207 work_id,
7208 )
7209
7210 def _apply_workplace_shot_continuous_generate(
7211 self,
7212 session_key: str,
7213 shot_id: int,
7214 ) -> tuple[str, dict[str, Any]]:
7215 """Submit an I2V continuous generation using the previous shot's tail frame."""
7216 self._ensure_echo_admission(operation="generate_echo_shot")
7217
7218 if shot_id <= 1:
7219 raise ValueError("continuous generation is only available for shot_id > 1")
7220
7221 loaded = self._load_workplace_shot(session_key, shot_id)
7222 if loaded is None:
7223 raise ValueError("shot not found")
7224 work_id, _shot_path, _state, shot = loaded
7225
7226 if not shot.get("continuous_enabled"):
7227 raise ValueError(
7228 f"shot {shot_id} does not have continuous mode enabled; "
7229 "set continuous_enabled first via /continuous-mode"
7230 )
7231
7232 self._lock_workplace_video_size(session_key, work_id)
7233
7234 status = str(shot.get("status") or "")
7235 if status == "queued":
7236 raise ValueError(f"shot {shot_id} generation already in progress")
7237 if status in {"generated", "review_pass", "approved"}:
7238 raise ValueError(f"shot {shot_id} is already generated")
7239
7240 # Load the previous shot and verify it has a usable artifact_url.
7241 previous_shot_id = shot_id - 1
7242 prev_loaded = self._load_workplace_shot(session_key, previous_shot_id)
7243 if prev_loaded is None:
7244 raise ValueError(f"previous shot {previous_shot_id} not found")
7245 _prev_work_id, prev_shot_path, _prev_state, prev_shot = prev_loaded
7246
7247 prev_status = str(prev_shot.get("status") or "")
7248 if prev_status not in {"generated", "review_pass", "approved"}:
7249 raise ValueError(
7250 f"previous shot {previous_shot_id} must be generated first (current: {prev_status})"
7251 )
7252
7253 video_url = prev_shot.get("artifact_url") or (
7254 prev_shot.get("echo") or {}
7255 ).get("result_url")
7256 if not video_url:
7257 raise ValueError(f"previous shot {previous_shot_id} has no artifact_url")
7258
7259 # Extract tail frame from the previous shot's video and publish locally.
7260 logger.info(
7261 "continuous-generate: shot_id={} using previous shot {} tail frame, "
7262 "extracting from video_url={}",
7263 shot_id, previous_shot_id, video_url,
7264 )
7265 condition_image_url = self._extract_and_publish_tail_frame(
7266 work_id, previous_shot_id, video_url
7267 )
7268 if not condition_image_url:
7269 raise ValueError(
7270 f"failed to extract tail frame for shot {previous_shot_id}"
7271 )
7272 logger.info(
7273 "continuous-generate: tail frame ready, shot_id={} condition_image_url={}",
7274 shot_id, condition_image_url,
7275 )
7276
7277 # Persist tail_frame_url on previous shot for caching and UI display.
7278 prev_shot["tail_frame_url"] = condition_image_url
7279 self._save_workplace_shot(prev_shot_path, prev_shot)
7280
7281 self._ensure_workplace_shot_references(work_id)
7282 if "planned_reference_shot_ids" not in shot:
7283 shot = self._read_json_file(_shot_path, {})
7284 if "planned_reference_shot_ids" not in shot:
7285 raise ValueError(
7286 "reference plan is not ready; complete the previous workflow step first"
7287 )
7288 reference_shot_ids = self._planned_reference_shot_ids(shot)
7289 # Ensure the previous shot is included as a reference.
7290 if previous_shot_id not in reference_shot_ids:
7291 reference_shot_ids = sorted(set(reference_shot_ids) | {previous_shot_id})
7292 missing = self._missing_reference_generations(work_id, reference_shot_ids)
7293 if missing:
7294 raise ValueError(self._format_reference_dependency_error(shot_id, missing))
7295
7296 selection_note = shot.get("reference_selection_note")
7297 note = selection_note if isinstance(selection_note, str) else None
7298 generate_tool = self._director_generate_tool()
7299 generate_tool.set_context(
7300 "websocket",
7301 webui_wire_chat_id(session_key) or "direct",
7302 effective_key=session_key,
7303 )
7304 generate_tool.apply_generate_continuous(
7305 work_id,
7306 shot_id,
7307 condition_image_url,
7308 reference_shot_ids,
7309 selection_note=note,
7310 )
7311 return work_id, self._build_workplace_payload(session_key)
7312
7313 async def _handle_workplace_workflow_generate_all(
7314 self, request: WsRequest, key: str
7315 ) -> Response:
7316 logger.info("workplace HTTP workflow/generate-all session_key={}", key)
7317 if not self._check_api_token(request):
7318 return _http_error(401, "Unauthorized")
7319 legacy_err = self._legacy_webui_session_key_error(key)
7320 if legacy_err is not None:
7321 return legacy_err
7322 decoded_key = self._resolve_webui_api_session_key(key, request)
7323 if decoded_key is None:
7324 return _http_error(404, "session not found")
7325 try:
7326 work_id, workplace, submitted = await asyncio.to_thread(
7327 self._apply_workplace_generate_all,
7328 decoded_key,
7329 )
7330 except (EchoGeneratorBusyError, EchoGeneratorUnavailableError) as exc:
7331 return self._http_echo_gate_error(decoded_key, exc)
7332 except ValueError as exc:
7333 status = 409 if "already" in str(exc).lower() else 400
7334 return _http_error(status, str(exc))
7335 try:
7336 loop = asyncio.get_running_loop()
7337 loop.create_task(self._publish_workplace_update(decoded_key))
7338 except RuntimeError:
7339 pass
7340 return _http_json_response(
7341 {
7342 "ok": True,
7343 "action": "generate_all",
7344 "work_id": work_id,
7345 "submitted_shot_ids": submitted,
7346 "workplace": workplace,
7347 }
7348 )
7349
7350 def _handle_workplace_workflow_auto_generate(self, request: WsRequest, key: str) -> Response:
7351 logger.info("workplace HTTP workflow/auto-generate session_key={}", key)
7352 resolved = self._resolve_workplace_request(request, key)
7353 if not isinstance(resolved, tuple):
7354 return resolved
7355 decoded_key, _work_id = resolved
7356 duration_raw = _query_first(_parse_query(request.path), "duration_sec")
7357 body = self._parse_json_body_payload(request)
7358 if duration_raw is None and isinstance(body, dict):
7359 duration_raw = body.get("duration_sec") or body.get("durationSec")
7360 try:
7361 work_id, workplace = self._apply_workplace_auto_generate(
7362 decoded_key,
7363 duration_sec=duration_raw,
7364 )
7365 except (EchoGeneratorBusyError, EchoGeneratorUnavailableError) as exc:
7366 return self._http_echo_gate_error(decoded_key, exc)
7367 except ValueError as exc:
7368 logger.error(
7369 "workplace auto-generate failed session_key={} error={}",
7370 decoded_key,
7371 exc,
7372 )
7373 status = 409 if "already" in str(exc).lower() else 400
7374 return _http_error(status, str(exc))
7375 try:
7376 loop = asyncio.get_running_loop()
7377 loop.create_task(self._publish_workplace_update(decoded_key))
7378 except RuntimeError:
7379 pass
7380 return _http_json_response(
7381 {
7382 "ok": True,
7383 "action": "auto_generate",
7384 "work_id": work_id,
7385 "workplace": workplace,
7386 }
7387 )
7388
7389 def _sync_auto_generate_n_shots(
7390 self,
7391 metadata: dict[str, Any],
7392 *,
7393 duration_sec: Any = None,
7394 locked_shot_count: int | None = None,
7395 ) -> None:
7396 """Persist generation shot count only while the workplace is unlocked."""
7397 from nanobot.session.generation_settings import SESSION_NSHOT_KEY, apply_generation_settings
7398
7399 if locked_shot_count:
7400 return
7401 if duration_sec not in (None, ""):
7402 n_shots = shot_count_for_auto_generate(duration_sec)
7403 apply_generation_settings(
7404 metadata,
7405 n_shots=n_shots,
7406 duration_sec=int(duration_sec),
7407 )
7408 return
7409 if SESSION_NSHOT_KEY not in metadata:
7410 apply_generation_settings(
7411 metadata,
7412 n_shots=shot_count_for_auto_generate(DEFAULT_AUTO_GENERATE_DURATION_SEC),
7413 duration_sec=DEFAULT_AUTO_GENERATE_DURATION_SEC,
7414 )
7415
7416 def _apply_workplace_auto_generate(
7417 self,
7418 session_key: str,
7419 *,
7420 duration_sec: Any = None,
7421 ) -> tuple[str, dict[str, Any]]:
7422 if self._session_manager is None:
7423 raise ValueError("session manager unavailable")
7424
7425 work_id = self._resolve_work_id_for_session(session_key)
7426 if not work_id:
7427 raise ValueError("director work not found")
7428 state = self._load_workplace_state(work_id)
7429 locked_n = locked_shot_count_from_state(state)
7430
7431 session = self._session_manager.get_or_create(session_key)
7432 apply_auto_generate(session.metadata, True)
7433 try:
7434 self._sync_auto_generate_n_shots(
7435 session.metadata,
7436 duration_sec=duration_sec,
7437 locked_shot_count=locked_n,
7438 )
7439 except ValueError as exc:
7440 logger.error(
7441 "workplace auto-generate invalid duration_sec={} session_key={} error={}",
7442 duration_sec,
7443 session_key,
7444 exc,
7445 )
7446 raise
7447 self._session_manager.save(session)
7448 state["auto_generate"] = True
7449 self._save_workplace_state(work_id, state)
7450 self._disable_auto_generate_continuous(session_key, work_id)
7451 profile = self._load_workplace_story_profile(work_id)
7452 beats = profile.get("beats") if isinstance(profile.get("beats"), list) else []
7453 beat_count = len(beats)
7454 specs_ready = self._workplace_shot_specs_complete_on_disk(
7455 work_id, beat_count=beat_count
7456 )
7457 refs_ready = self._workplace_references_ready(work_id, beat_count=beat_count)
7458 if specs_ready and refs_ready:
7459 disk_shots = self._workplace_disk_shots(work_id)
7460 if any(str(shot.get("status") or "") == "queued" for shot in disk_shots):
7461 self._continue_auto_generate(session_key)
7462 return work_id, self._build_workplace_payload(session_key)
7463 try:
7464 work_id, workplace, _submitted = self._apply_workplace_generate_all(session_key)
7465 return work_id, workplace
7466 except EchoGeneratorUnavailableError:
7467 raise
7468 except ValueError as exc:
7469 logger.info(
7470 "auto_generate start generate_all skipped session_key={} work_id={} error={}",
7471 session_key,
7472 work_id,
7473 exc,
7474 )
7475 self._continue_auto_generate(session_key)
7476 return work_id, self._build_workplace_payload(session_key)
7477 return self._apply_workplace_start_generation(session_key)
7478
7479 def _workplace_auto_generate_active(
7480 self, session_key: str, state: dict[str, Any] | None = None
7481 ) -> bool:
7482 if isinstance(state, dict) and bool(state.get("auto_generate")):
7483 return True
7484 work_id = self._resolve_work_id_for_session(session_key)
7485 if work_id:
7486 loaded = state if isinstance(state, dict) else self._load_workplace_state(work_id)
7487 if bool(loaded.get("auto_generate")):
7488 return True
7489 return self._session_auto_generate_flag(session_key)
7490
7491 def _disable_auto_generate_continuous(self, session_key: str, work_id: str) -> None:
7492 """Disable tail-frame continuation while auto-generation is active."""
7493 state = self._load_workplace_state(work_id)
7494 changed = False
7495 for shot_id, shot in self._iter_workplace_shots(work_id):
7496 if not shot.get("continuous_enabled"):
7497 continue
7498 loaded = self._load_workplace_shot(session_key, shot_id)
7499 if loaded is None:
7500 continue
7501 _work, shot_path, _state, current = loaded
7502 current["continuous_enabled"] = False
7503 self._save_workplace_shot(shot_path, current)
7504 self._sync_state_shot_row(state, current)
7505 changed = True
7506 logger.info(
7507 "auto_generate disabled continuous session_key={} work_id={} shot_id={}",
7508 session_key,
7509 work_id,
7510 shot_id,
7511 )
7512 if changed:
7513 self._save_workplace_state(work_id, state)
7514
7515 def _clear_auto_generate_memory_wait(self, work_id: str) -> None:
7516 state = self._load_workplace_state(work_id)
7517 if not state.get("auto_generate_waited_memory"):
7518 return
7519 state["auto_generate_waited_memory"] = False
7520 self._save_workplace_state(work_id, state)
7521
7522 def _auto_generate_memory_status(self, shot: dict[str, Any]) -> str:
7523 review = shot.get("memory_review")
7524 if not isinstance(review, dict):
7525 return ""
7526 return str(review.get("status") or "")
7527
7528 def _auto_generate_shot_ready(self, shot: dict[str, Any]) -> bool:
7529 if str(shot.get("status") or "") != "approved":
7530 return False
7531 if not self._memory_review_workflow_enabled():
7532 return True
7533 status = self._auto_generate_memory_status(shot)
7534 if status == "approved":
7535 return True
7536 return False
7537
7538 def _schedule_auto_generate_continue(self, session_key: str) -> None:
7539 """Continue auto-generation off the outbound send path.
7540
7541 ``_continue_auto_generate`` may block on urllib to Echo. Running it
7542 inside ``send()`` freezes the gateway event loop when the video
7543 service is down.
7544 """
7545 if not session_key or self._session_manager is None:
7546 return
7547 if session_key in self._auto_generate_inflight:
7548 return
7549 if not self._workplace_auto_generate_active(session_key):
7550 return
7551 self._auto_generate_inflight.add(session_key)
7552
7553 def _run() -> None:
7554 try:
7555 self._continue_auto_generate(session_key)
7556 except Exception:
7557 logger.opt(exception=True).error(
7558 "auto_generate continue failed session_key={}",
7559 session_key,
7560 )
7561
7562 async def _wrapped() -> None:
7563 try:
7564 await asyncio.to_thread(_run)
7565 finally:
7566 self._auto_generate_inflight.discard(session_key)
7567
7568 if not self._schedule_coro(
7569 lambda: _wrapped(),
7570 warning=(
7571 "websocket: unable to schedule auto_generate continue "
7572 f"session_key={session_key}"
7573 ),
7574 ):
7575 try:
7576 _run()
7577 finally:
7578 self._auto_generate_inflight.discard(session_key)
7579
7580 def _continue_auto_generate(self, session_key: str) -> None:
7581 session = self._session_manager.get_or_create(session_key)
7582 metadata = session.metadata if isinstance(session.metadata, dict) else {}
7583 work_id = self._resolve_work_id_for_session(session_key)
7584 if not work_id:
7585 return
7586 state = self._load_workplace_state(work_id)
7587 auto = bool(state.get("auto_generate")) or get_auto_generate(metadata)
7588 if not auto:
7589 return
7590 if not bool(state.get("auto_generate")):
7591 state["auto_generate"] = True
7592 self._save_workplace_state(work_id, state)
7593 self._disable_auto_generate_continuous(session_key, work_id)
7594 if state.get("final_output_url") or state.get("final_output_path"):
7595 return
7596 if str(state.get("stage") or "") == "merging":
7597 logger.info(
7598 "auto_generate waiting for merge session_key={} work_id={}",
7599 session_key,
7600 work_id,
7601 )
7602 return
7603 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
7604 try:
7605 locked_shot_count = int(goal.get("shot_count") or 0)
7606 except (TypeError, ValueError):
7607 locked_shot_count = 0
7608 self._reconcile_workplace_state_shots(work_id, state)
7609 pending_kinds = {
7610 str(item.get("kind"))
7611 for item in self._workplace_pending_remote_jobs(state).values()
7612 if isinstance(item, dict)
7613 }
7614 if pending_kinds.intersection({"generate_echo_shot", "merge_shot"}):
7615 logger.info(
7616 "auto_generate waiting for pending jobs session_key={} work_id={} kinds={}",
7617 session_key,
7618 work_id,
7619 sorted(pending_kinds),
7620 )
7621 return
7622 disk_shots = self._workplace_disk_shots(work_id)
7623 if any(str(shot.get("status") or "") == "queued" for shot in disk_shots):
7624 logger.info(
7625 "auto_generate waiting for queued shot session_key={} work_id={}",
7626 session_key,
7627 work_id,
7628 )
7629 return
7630 retry_count = int(state.get("auto_generate_retry_count") or 0)
7631 for shot in disk_shots:
7632 status = str(shot.get("status") or "")
7633 shot_id = int(shot.get("shot_id") or 0)
7634 if shot_id <= 0:
7635 continue
7636 if status == "error":
7637 generation_error = str(shot.get("generation_error") or "")
7638 logger.error(
7639 "auto_generate shot error session_key={} work_id={} shot_id={} error={}",
7640 session_key,
7641 work_id,
7642 shot_id,
7643 generation_error or "unknown",
7644 )
7645 if UNAVAILABLE_MESSAGE in generation_error:
7646 return
7647 if retry_count < 1:
7648 state["auto_generate_retry_count"] = retry_count + 1
7649 self._save_workplace_state(work_id, state)
7650 try:
7651 self._apply_workplace_shot_generate(session_key, shot_id)
7652 except EchoGeneratorUnavailableError as exc:
7653 self._report_echo_unavailable(
7654 session_key, exc, work_id=work_id, shot_id=shot_id
7655 )
7656 except Exception:
7657 logger.opt(exception=True).error(
7658 "auto_generate retry failed session_key={} work_id={} shot_id={}",
7659 session_key,
7660 work_id,
7661 shot_id,
7662 )
7663 return
7664 state["generation_error"] = generation_error or "镜头生成失败"
7665 self._save_workplace_state(work_id, state)
7666 return
7667 if status == "generated":
7668 try:
7669 self._apply_workplace_review(
7670 session_key,
7671 shot_id,
7672 verdict="accept",
7673 review_source="auto",
7674 )
7675 except ValueError as exc:
7676 logger.error(
7677 "auto_generate accept failed session_key={} work_id={} "
7678 "shot_id={} error={}",
7679 session_key,
7680 work_id,
7681 shot_id,
7682 exc,
7683 )
7684 return
7685 self._auto_approve_memory_review_if_needed(session_key, shot_id)
7686
7687 state = self._load_workplace_state(work_id)
7688 self._reconcile_workplace_state_shots(work_id, state)
7689 disk_shots = self._workplace_disk_shots(work_id)
7690 memory_busy = False
7691 approved_memory = False
7692 for shot in disk_shots:
7693 shot_id = int(shot.get("shot_id") or 0)
7694 if shot_id <= 0:
7695 continue
7696 memory_status = self._auto_generate_memory_status(shot)
7697 if memory_status == "awaiting_review":
7698 self._auto_approve_memory_review_if_needed(session_key, shot_id)
7699 approved_memory = True
7700 continue
7701 if memory_status in {"selecting", "reselecting"}:
7702 memory_busy = True
7703 continue
7704 video_status = str(shot.get("status") or "")
7705 if (
7706 self._memory_review_workflow_enabled()
7707 and video_status in {"generated", "review_pass", "approved"}
7708 and not memory_status
7709 ):
7710 memory_busy = True
7711
7712 if approved_memory:
7713 state = self._load_workplace_state(work_id)
7714 state["auto_generate_waited_memory"] = False
7715 self._save_workplace_state(work_id, state)
7716 if memory_busy:
7717 state = self._load_workplace_state(work_id)
7718 state["auto_generate_waited_memory"] = True
7719 self._save_workplace_state(work_id, state)
7720 logger.info(
7721 "auto_generate waiting for memory session_key={} work_id={}",
7722 session_key,
7723 work_id,
7724 )
7725 return
7726
7727 state = self._load_workplace_state(work_id)
7728 self._reconcile_workplace_state_shots(work_id, state)
7729 pending_kinds = {
7730 str(item.get("kind"))
7731 for item in self._workplace_pending_remote_jobs(state).values()
7732 if isinstance(item, dict)
7733 }
7734 if pending_kinds.intersection({"generate_echo_shot", "merge_shot"}):
7735 logger.info(
7736 "auto_generate waiting for pending jobs session_key={} work_id={} kinds={}",
7737 session_key,
7738 work_id,
7739 sorted(pending_kinds),
7740 )
7741 return
7742 disk_shots = self._workplace_disk_shots(work_id)
7743 if any(str(shot.get("status") or "") == "queued" for shot in disk_shots):
7744 logger.info(
7745 "auto_generate waiting for queued shot session_key={} work_id={}",
7746 session_key,
7747 work_id,
7748 )
7749 return
7750 if disk_shots and all(
7751 self._auto_generate_shot_ready(shot)
7752 for shot in disk_shots
7753 ):
7754 self._schedule_auto_generate_merge(session_key, work_id, state)
7755 return
7756 if locked_shot_count <= 0:
7757 return
7758 predecessors_ready = True
7759 generated_incomplete = False
7760 for shot in disk_shots:
7761 status = str(shot.get("status") or "")
7762 if status in {"queued", "generated", "review_pass"}:
7763 generated_incomplete = True
7764 if status in {"queued", "generated", "review_pass", "approved"} and not self._auto_generate_shot_ready(shot):
7765 predecessors_ready = False
7766 if generated_incomplete or not predecessors_ready:
7767 logger.info(
7768 "auto_generate holding generate_all session_key={} work_id={} "
7769 "generated_incomplete={} predecessors_ready={}",
7770 session_key,
7771 work_id,
7772 generated_incomplete,
7773 predecessors_ready,
7774 )
7775 return
7776 profile = self._load_workplace_story_profile(work_id)
7777 beats = profile.get("beats") if isinstance(profile.get("beats"), list) else []
7778 beat_count = len(beats)
7779 if self._workplace_shot_specs_complete_on_disk(
7780 work_id, beat_count=beat_count
7781 ) and self._workplace_references_ready(work_id, beat_count=beat_count):
7782 try:
7783 self._apply_workplace_generate_all(session_key)
7784 except EchoGeneratorUnavailableError as exc:
7785 self._report_echo_unavailable(session_key, exc, work_id=work_id)
7786 return
7787 except EchoGeneratorBusyError as exc:
7788 logger.error(
7789 "auto_generate generate_all busy session_key={} work_id={} error={}",
7790 session_key,
7791 work_id,
7792 exc,
7793 )
7794 return
7795 except ValueError as exc:
7796 logger.info(
7797 "auto_generate generate_all skipped session_key={} work_id={} error={}",
7798 session_key,
7799 work_id,
7800 exc,
7801 )
7802 return
7803 if state.get("story_confirmed") and str(state.get("stage") or "") not in {
7804 "shot_generating",
7805 "merging",
7806 "done",
7807 }:
7808 try:
7809 self._apply_workplace_start_generation(session_key)
7810 except ValueError as exc:
7811 logger.info(
7812 "auto_generate start_generation skipped session_key={} error={}",
7813 session_key,
7814 exc,
7815 )
7816
7817 def _auto_approve_memory_review_if_needed(self, session_key: str, shot_id: int) -> None:
7818 if not self._memory_review_workflow_enabled():
7819 return
7820 loaded = self._load_workplace_shot(session_key, shot_id)
7821 if loaded is None:
7822 return
7823 _work_id, _path, _state, shot = loaded
7824 review = shot.get("memory_review")
7825 if not isinstance(review, dict) or review.get("status") != "awaiting_review":
7826 return
7827 try:
7828 self._apply_memory_review_action(
7829 session_key,
7830 shot_id,
7831 action="approve",
7832 review_id=str(review.get("review_id") or ""),
7833 attempt=int(review.get("attempt") or 1),
7834 )
7835 except Exception:
7836 logger.opt(exception=True).error(
7837 "auto_generate memory approve failed session_key={} shot_id={}",
7838 session_key,
7839 shot_id,
7840 )
7841
7842 def _schedule_auto_generate_merge(
7843 self,
7844 session_key: str,
7845 work_id: str,
7846 state: dict[str, Any],
7847 ) -> None:
7848 if state.get("final_output_url") or state.get("final_output_path"):
7849 return
7850 pending_kinds = {
7851 str(item.get("kind"))
7852 for item in self._workplace_pending_remote_jobs(state).values()
7853 if isinstance(item, dict)
7854 }
7855 if pending_kinds.intersection({"merge_shot"}):
7856 return
7857 shot_entries = self._workplace_shot_entries(state)
7858 approval_error = self._auto_approve_workplace_shots(session_key, shot_entries)
7859 if approval_error:
7860 logger.error(
7861 "auto_generate merge blocked session_key={} work_id={} error={}",
7862 session_key,
7863 work_id,
7864 approval_error,
7865 )
7866 return
7867 state = self._load_workplace_state(work_id)
7868 if not state.get("review_completed_at"):
7869 state["review_completed_at"] = (
7870 datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
7871 )
7872 state["stage"] = "merging"
7873 self._save_workplace_state(work_id, state)
7874 logger.info(
7875 "auto_generate merge starting session_key={} work_id={}",
7876 session_key,
7877 work_id,
7878 )
7879 if not self._schedule_coro(
7880 lambda: self._complete_auto_generate_merge(session_key, work_id),
7881 ):
7882 self._schedule_workplace_start_merge_instruction(session_key, work_id=work_id)
7883
7884 async def _complete_auto_generate_merge(self, session_key: str, work_id: str) -> None:
7885 try:
7886 await asyncio.to_thread(self._submit_workplace_merge, session_key, work_id)
7887 except Exception:
7888 logger.opt(exception=True).error(
7889 "auto_generate merge submit failed session_key={} work_id={}",
7890 session_key,
7891 work_id,
7892 )
7893 self._schedule_workplace_start_merge_instruction(session_key, work_id=work_id)
7894 try:
7895 await self._publish_workplace_update(session_key)
7896 except Exception:
7897 logger.opt(exception=True).error(
7898 "auto_generate merge update failed session_key={} work_id={}",
7899 session_key,
7900 work_id,
7901 )
7902
7903 def _submit_workplace_merge(self, session_key: str, work_id: str) -> dict[str, Any]:
7904 merge_tool = self._director_merge_tool()
7905 merge_tool.set_context(
7906 "websocket",
7907 webui_wire_chat_id(session_key) or "direct",
7908 effective_key=session_key,
7909 )
7910 token = _WORKFLOW_INJECTED_EVENT.set(WORKFLOW_GATE_BYPASS)
7911 try:
7912 job = merge_tool.apply_merge(work_id=work_id)
7913 finally:
7914 _WORKFLOW_INJECTED_EVENT.reset(token)
7915 logger.info(
7916 "auto_generate merge submitted session_key={} work_id={} job_id={}",
7917 session_key,
7918 work_id,
7919 job.get("job_id"),
7920 )
7921 return job
7922
7923 def _auto_approve_workplace_shots(self, session_key: str, shot_entries: list[dict[str, Any]]) -> str | None:
7924 """Accept every generated shot. Returns an error message or None on success."""
7925 for item in shot_entries:
7926 status = str(item.get("status") or "")
7927 if status in {"review_fail", "error"}:
7928 shot_id = item.get("shot_id")
7929 return f"resolve failed shot {shot_id} before merging"
7930 if status in {"queued", "prompt_ready", "revised_prompt_ready", "planned"}:
7931 shot_id = item.get("shot_id")
7932 return f"finish generation for shot {shot_id} before merging"
7933 for item in shot_entries:
7934 status = str(item.get("status") or "")
7935 shot_id = int(item.get("shot_id") or 0)
7936 if shot_id <= 0:
7937 continue
7938 if status in {"generated", "review_pass"}:
7939 try:
7940 self._apply_workplace_review(
7941 session_key, shot_id, verdict="accept", review_source="auto"
7942 )
7943 except ValueError as exc:
7944 return str(exc)
7945 self._auto_approve_memory_review_if_needed(session_key, shot_id)
7946 loaded = self._load_workplace_shot(session_key, shot_id)
7947 if loaded is not None:
7948 _work, _path, _state, latest = loaded
7949 if str(latest.get("status") or "") != "approved":
7950 return f"shot {shot_id} is not ready to merge"
7951 elif status != "approved":
7952 return f"shot {shot_id} is not ready to merge"
7953 return None
7954
7955 def _handle_workplace_workflow_regenerate(self, request: WsRequest, key: str) -> Response:
7956 logger.info("workplace HTTP workflow/regenerate session_key={}", key)
7957 if not self._check_api_token(request):
7958 return _http_error(401, "Unauthorized")
7959 legacy_err = self._legacy_webui_session_key_error(key)
7960 if legacy_err is not None:
7961 return legacy_err
7962 decoded_key = self._resolve_webui_api_session_key(key, request)
7963 if decoded_key is None:
7964 return _http_error(404, "session not found")
7965 try:
7966 work_id, workplace = self._apply_workplace_regenerate(decoded_key)
7967 except ValueError as exc:
7968 return _http_error(400, str(exc))
7969 try:
7970 loop = asyncio.get_running_loop()
7971 loop.create_task(self._publish_workplace_update(decoded_key))
7972 except RuntimeError:
7973 pass
7974 return _http_json_response(
7975 {
7976 "ok": True,
7977 "action": "regenerate",
7978 "work_id": work_id,
7979 "workplace": workplace,
7980 }
7981 )
7982
7983 def _download_url_policy(self) -> DownloadUrlPolicy:
7984 suffixes = tuple(
7985 suffix.strip().lower()
7986 for suffix in self.config.download_allowed_domain_suffixes
7987 if isinstance(suffix, str) and suffix.strip()
7988 )
7989 trusted = frozenset(
7990 domain.strip().lower()
7991 for domain in self.config.download_trusted_internal_domains
7992 if isinstance(domain, str) and domain.strip()
7993 )
7994 return DownloadUrlPolicy(
7995 allowed_domain_suffixes=suffixes,
7996 trusted_internal_domains=trusted,
7997 )
7998
7999 def _single_shot_video_locator(self, work_id: str) -> str | None:
8000 """When there is exactly one generated shot, use it as the downloadable final.
8001
8002 Matches ``_build_workplace_payload`` which exposes that shot as ``final_video``
8003 for short-video / one-shot flows before an explicit merge writes final_output_*.
8004 """
8005 paths = self._workplace_paths(work_id)
8006 if paths is None:
8007 return None
8008 shot_paths = sorted(paths["shots"].glob("shot_*.json"))
8009 if len(shot_paths) != 1:
8010 return None
8011 shot = self._read_json_file(shot_paths[0], {})
8012 if not isinstance(shot, dict):
8013 return None
8014 remote = shot.get("remote_result") if isinstance(shot.get("remote_result"), dict) else {}
8015 for candidate in (
8016 shot.get("artifact_url"),
8017 shot.get("artifact_path"),
8018 remote.get("video_path"),
8019 ):
8020 if isinstance(candidate, str) and candidate.strip():
8021 return candidate.strip()
8022 return None
8023
8024 def _resolve_workplace_final_video_locator(self, session_key: str) -> tuple[str, str, str]:
8025 """Return ``(work_id, locator, filename)`` for the session's final video."""
8026 work_id = self._resolve_work_id_for_session(session_key)
8027 if work_id:
8028 paths = self._workplace_paths(work_id)
8029 if paths is None:
8030 raise ValueError("director work not found")
8031 state = self._read_json_file(paths["state"], {})
8032 if not isinstance(state, dict):
8033 state = {}
8034 locator = None
8035 for candidate in (state.get("final_output_url"), state.get("final_output_path")):
8036 if isinstance(candidate, str) and candidate.strip():
8037 locator = candidate.strip()
8038 break
8039 if not locator:
8040 # Short video: single generated shot is treated as the final for download.
8041 locator = self._single_shot_video_locator(work_id)
8042 if not locator:
8043 raise ValueError("final video is not ready")
8044 if not urlparse(locator).scheme and not Path(locator).is_absolute():
8045 for base_dir in (paths["work_dir"], paths["work_dir"] / "outputs", paths["shots"]):
8046 candidate = base_dir / locator
8047 if candidate.is_file():
8048 locator = str(candidate)
8049 break
8050 filename = _media_display_name(locator) or f"{work_id}-final.mp4"
8051 return work_id, locator, filename
8052
8053 raise ValueError("director work not found")
8054
8055 def _fetch_workplace_final_video(
8056 self,
8057 locator: str,
8058 filename: str,
8059 ) -> tuple[bytes, str, str]:
8060 """Download final video bytes and return ``(data, content_type, filename)``."""
8061 parsed = urlparse(locator)
8062 if parsed.scheme in _REMOTE_MEDIA_SCHEMES:
8063 configure_download_policy(self._download_url_policy())
8064 try:
8065 validate_external_url(locator)
8066 except UrlValidationError as exc:
8067 logger.warning("final video URL blocked by SSRF policy: {}", exc)
8068 raise ValueError("URL安全校验失败") from exc
8069 try:
8070 result = download_http_bytes(
8071 locator,
8072 max_bytes=int(self.config.download_max_bytes),
8073 timeout_s=float(self.config.download_timeout_s),
8074 )
8075 except HttpDownloadError as exc:
8076 logger.warning("final video HTTP download failed: {}", exc)
8077 raise ValueError("下载失败") from exc
8078 return result.data, result.content_type, filename
8079
8080 local = Path(unquote(parsed.path)) if parsed.scheme == "file" else Path(locator).expanduser()
8081 try:
8082 candidate = local.resolve()
8083 except OSError as exc:
8084 raise ValueError("final video is not ready") from exc
8085 if not candidate.is_file():
8086 raise ValueError("final video is not ready")
8087 max_bytes = int(self.config.download_max_bytes)
8088 try:
8089 size = candidate.stat().st_size
8090 except OSError as exc:
8091 raise ValueError("final video is not ready") from exc
8092 if size > max_bytes:
8093 raise ValueError("文件大小超过限制")
8094 try:
8095 data = candidate.read_bytes()
8096 except OSError as exc:
8097 raise ValueError("下载失败") from exc
8098 mime, _ = mimetypes.guess_type(candidate.name)
8099 content_type = mime if mime in _MEDIA_ALLOWED_MIMES else "application/octet-stream"
8100 display_name = _media_display_name(str(candidate)) or filename
8101 return data, content_type, display_name
8102
8103 def _record_echo_video_download(self, session_key: str) -> None:
8104 tracking_ctx = self._resolve_echo_tracking_context(session_key)
8105 if tracking_ctx is None:
8106 return
8107 state = tracking_ctx["state"]
8108 state["video_downloaded"] = True
8109 state["updated_at"] = datetime.now(timezone.utc).isoformat()
8110 self._save_echo_tracking_context(tracking_ctx)
8111
8112 def _handle_workplace_download_final(self, request: WsRequest, key: str) -> Response:
8113 """Authenticated proxy download for the workplace final merged video."""
8114 logger.info("workplace HTTP download/final session_key={}", key)
8115 if not self._check_api_token(request):
8116 return _http_error(401, "Unauthorized")
8117 legacy_err = self._legacy_webui_session_key_error(key)
8118 if legacy_err is not None:
8119 return legacy_err
8120 decoded_key = self._resolve_webui_api_session_key(key, request)
8121 if decoded_key is None:
8122 return _http_error(404, "session not found")
8123 try:
8124 work_id, locator, filename = self._resolve_workplace_final_video_locator(decoded_key)
8125 data, content_type, filename = self._fetch_workplace_final_video(locator, filename)
8126 except ValueError as exc:
8127 message = str(exc)
8128 if message == "URL安全校验失败":
8129 return _http_json_response(
8130 {"code": 3, "message": message},
8131 status=400,
8132 )
8133 status = 404 if "not ready" in message or "not found" in message else 400
8134 if message == "下载失败":
8135 return _http_json_response({"code": 4, "message": message}, status=400)
8136 return _http_error(status, message)
8137 self._record_echo_video_download(decoded_key)
8138 safe_name = filename.replace('"', "")
8139 return _http_response(
8140 data,
8141 content_type=content_type,
8142 extra_headers=[
8143 ("Content-Disposition", f'attachment; filename="{safe_name}"'),
8144 ("Cache-Control", "private, no-store"),
8145 ("X-Content-Type-Options", "nosniff"),
8146 ],
8147 )
8148
8149 def _handle_workplace_workflow_start_merge(self, request: WsRequest, key: str) -> Response:
8150 logger.info("workplace HTTP workflow/start-merge session_key={}", key)
8151 resolved = self._resolve_workplace_request(request, key)
8152 if not isinstance(resolved, tuple):
8153 return resolved
8154 decoded_key, work_id = resolved
8155 paths = self._workplace_paths(work_id)
8156 if paths is None:
8157 return _http_error(404, "director work not found")
8158 state = self._read_json_file(paths["state"], {})
8159 if not isinstance(state, dict):
8160 state = {}
8161 self._reconcile_workplace_state_shots(work_id, state)
8162 if state.get("final_output_url") or state.get("final_output_path"):
8163 return _http_error(409, "work already completed")
8164 if not state.get("story_confirmed"):
8165 return _http_error(400, "story is not confirmed")
8166 shot_entries = self._workplace_shot_entries(state)
8167 if not shot_entries:
8168 return _http_error(400, "no shots found")
8169 pending_kinds = {
8170 str(item.get("kind"))
8171 for item in self._workplace_pending_remote_jobs(state).values()
8172 if isinstance(item, dict)
8173 }
8174 if "generate_echo_shot" in pending_kinds:
8175 return _http_error(409, "shot generation or review still in progress")
8176 if pending_kinds.intersection({"merge_shot"}):
8177 return _http_error(409, "merge already in progress")
8178 approval_error = self._auto_approve_workplace_shots(decoded_key, shot_entries)
8179 if approval_error:
8180 return _http_error(400, approval_error)
8181 state = self._read_json_file(paths["state"], {})
8182 if not isinstance(state, dict):
8183 state = {}
8184 shot_entries = self._workplace_shot_entries(state)
8185 if not all(str(item.get("status") or "") == "approved" for item in shot_entries):
8186 return _http_error(400, "accept every shot before merging")
8187 if not state.get("review_completed_at"):
8188 state["review_completed_at"] = (
8189 datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
8190 )
8191 state["stage"] = "merging"
8192 self._save_workplace_state(work_id, state)
8193 self._schedule_workplace_start_merge_instruction(decoded_key, work_id=work_id)
8194 workplace = self._build_workplace_payload(decoded_key)
8195 try:
8196 loop = asyncio.get_running_loop()
8197 loop.create_task(self._publish_workplace_update(decoded_key))
8198 except RuntimeError:
8199 pass
8200 return _http_json_response(
8201 {
8202 "ok": True,
8203 "action": "start_merge",
8204 "work_id": work_id,
8205 "scheduled": True,
8206 "workplace": workplace,
8207 }
8208 )
8209
8210 def _handle_promptstack_sessions(self, request: WsRequest) -> Response:
8211 if not self._check_api_token(request):
8212 return _http_error(401, "Unauthorized")
8213 from nanobot.agent.prompt_stacker import PromptStacker
8214 sessions = PromptStacker.get_sessions()
8215 return _http_json_response(sessions)
8216
8217 def _handle_promptstack_trace(self, request: WsRequest, session_id: str) -> Response:
8218 if not self._check_api_token(request):
8219 return _http_error(401, "Unauthorized")
8220 from nanobot.agent.prompt_stacker import PromptStacker
8221 trace = PromptStacker.get_trace(session_id)
8222 if not trace:
8223 return _http_json_response({"error": "not found"}, status=404)
8224 return _http_json_response(trace)
8225
8226 def _handle_eventstack_sessions(self, request: WsRequest) -> Response:
8227 if not self._check_api_token(request):
8228 return _http_error(401, "Unauthorized")
8229 from nanobot.agent.event_stacker import EventStacker
8230 sessions = EventStacker.get_sessions()
8231 return _http_json_response(sessions)
8232
8233 def _handle_eventstack_trace(self, request: WsRequest, session_id: str) -> Response:
8234 if not self._check_api_token(request):
8235 return _http_error(401, "Unauthorized")
8236 from nanobot.agent.event_stacker import EventStacker
8237 trace = EventStacker.get_trace(session_id)
8238 if not trace:
8239 return _http_json_response({"error": "not found"}, status=404)
8240 return _http_json_response(trace)
8241
8242 def _serve_static(self, request_path: str) -> Response | None:
8243 """Resolve *request_path* against the built SPA directory; SPA fallback to index.html."""
8244 assert self._static_dist_path is not None
8245 rel = request_path.lstrip("/")
8246 if not rel:
8247 rel = "index.html"
8248 # Reject path-traversal attempts and absolute targets.
8249 if ".." in rel.split("/") or rel.startswith("/"):
8250 return _http_error(403, "Forbidden")
8251 candidate = (self._static_dist_path / rel).resolve()
8252 try:
8253 candidate.relative_to(self._static_dist_path)
8254 except ValueError:
8255 return _http_error(403, "Forbidden")
8256 if not candidate.is_file():
8257 # SPA history-mode fallback: unknown routes serve index.html so the
8258 # client-side router can render them.
8259 index = self._static_dist_path / "index.html"
8260 if index.is_file():
8261 candidate = index
8262 else:
8263 return None
8264 try:
8265 body = candidate.read_bytes()
8266 except OSError as e:
8267 logger.warning("websocket static: failed to read {}: {}", candidate, e)
8268 return _http_error(500, "Internal Server Error")
8269 ctype, _ = mimetypes.guess_type(candidate.name)
8270 if ctype is None:
8271 ctype = "application/octet-stream"
8272 if ctype.startswith("text/") or ctype in {"application/javascript", "application/json"}:
8273 ctype = f"{ctype}; charset=utf-8"
8274 # Hash-named build assets are cache-friendly; index.html must stay fresh.
8275 if candidate.name == "index.html":
8276 cache = "no-cache"
8277 else:
8278 cache = "public, max-age=31536000, immutable"
8279 return _http_response(
8280 body,
8281 status=200,
8282 content_type=ctype,
8283 extra_headers=[("Cache-Control", cache)],
8284 )
8285
8286 def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
8287 token = _query_first(query, "token")
8288 static_token = self.config.token.strip()
8289 if not self.config.websocket_requires_token and not static_token:
8290 return None
8291 if static_token and token and hmac.compare_digest(token, static_token):
8292 return None
8293 if self._take_issued_token_if_valid(token):
8294 return None
8295 return connection.respond(401, "Unauthorized")
8296
8297 async def start(self) -> None:
8298 self._running = True
8299 self._loop = asyncio.get_running_loop()
8300 self._stop_event = asyncio.Event()
8301
8302 ssl_context = self._build_ssl_context()
8303 scheme = "wss" if ssl_context else "ws"
8304
8305 async def process_request(
8306 connection: ServerConnection,
8307 request: WsRequest,
8308 ) -> Any:
8309 return await self._dispatch_http(connection, request)
8310
8311 async def handler(connection: ServerConnection) -> None:
8312 await self._connection_loop(connection)
8313
8314 logger.info(
8315 "WebSocket server listening on {}://{}:{}{}",
8316 scheme,
8317 self.config.host,
8318 self.config.port,
8319 self.config.path,
8320 )
8321 if self.config.token_issue_path:
8322 logger.info(
8323 "WebSocket token issue route: {}://{}:{}{}",
8324 scheme,
8325 self.config.host,
8326 self.config.port,
8327 _normalize_config_path(self.config.token_issue_path),
8328 )
8329
8330 async def runner() -> None:
8331 async with serve(
8332 handler,
8333 self.config.host,
8334 self.config.port,
8335 process_request=process_request,
8336 max_size=self.config.max_message_bytes,
8337 ping_interval=self.config.ping_interval_s,
8338 ping_timeout=self.config.ping_timeout_s,
8339 ssl=ssl_context,
8340 ):
8341 assert self._stop_event is not None
8342 await self._stop_event.wait()
8343
8344 self._server_task = asyncio.create_task(runner())
8345 await self._server_task
8346
8347 async def _connection_loop(self, connection: Any) -> None:
8348 request = connection.request
8349 path_part = request.path if request else "/"
8350 _, query = _parse_request_path(path_part)
8351 client_id_raw = _query_first(query, "client_id")
8352 client_id = client_id_raw.strip() if client_id_raw else ""
8353 if not client_id:
8354 client_id = f"anon-{uuid.uuid4().hex[:12]}"
8355 elif len(client_id) > 128:
8356 logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id))
8357 client_id = client_id[:128]
8358
8359 default_chat_id = str(uuid.uuid4())
8360 try:
8361 await connection.send(
8362 json.dumps(
8363 {
8364 "event": "ready",
8365 "chat_id": default_chat_id,
8366 "client_id": client_id,
8367 },
8368 ensure_ascii=False,
8369 )
8370 )
8371 # Register only after ready is successfully sent to avoid out-of-order sends
8372 self._conn_default[connection] = default_chat_id
8373 self._attach(connection, default_chat_id)
8374
8375 async for raw in connection:
8376 if isinstance(raw, bytes):
8377 try:
8378 raw = raw.decode("utf-8")
8379 except UnicodeDecodeError:
8380 logger.warning("websocket: ignoring non-utf8 binary frame")
8381 continue
8382
8383 envelope = _parse_envelope(raw)
8384 if envelope is not None:
8385 await self._dispatch_envelope(connection, client_id, envelope)
8386 continue
8387
8388 content = _parse_inbound_payload(raw)
8389 if content is None:
8390 continue
8391 await self._handle_message(
8392 sender_id=client_id,
8393 chat_id=default_chat_id,
8394 content=content,
8395 metadata={"remote": getattr(connection, "remote_address", None)},
8396 session_key=self._webui_session_key_for_connection(
8397 connection, default_chat_id
8398 ),
8399 )
8400 except Exception as e:
8401 logger.debug("websocket connection ended: {}", e)
8402 finally:
8403 self._cleanup_connection(connection)
8404
8405 @staticmethod
8406 def _save_envelope_media(
8407 media: list[Any],
8408 ) -> tuple[list[str], str | None]:
8409 """Decode and persist ``media`` items from a ``message`` envelope.
8410
8411 Returns ``(paths, None)`` on success or ``([], reason)`` on the first
8412 failure — the caller is expected to surface ``reason`` to the client
8413 and skip publishing so no half-formed message ever reaches the agent.
8414 On failure, any images already written to disk earlier in the same
8415 call are unlinked so partial ingress doesn't leak orphan files.
8416 ``reason`` is a short, stable token suitable for UI localization.
8417
8418 Shape: ``list[{"data_url": str, "name"?: str | None}]``.
8419 """
8420 if len(media) > _MAX_IMAGES_PER_MESSAGE:
8421 return [], "too_many_images"
8422 media_dir = get_media_dir("websocket")
8423 paths: list[str] = []
8424
8425 def _abort(reason: str) -> tuple[list[str], str]:
8426 for p in paths:
8427 try:
8428 Path(p).unlink(missing_ok=True)
8429 except OSError as exc:
8430 logger.warning(
8431 "websocket: failed to unlink partial media {}: {}", p, exc
8432 )
8433 return [], reason
8434
8435 for item in media:
8436 if not isinstance(item, dict):
8437 return _abort("malformed")
8438 data_url = item.get("data_url")
8439 if not isinstance(data_url, str) or not data_url:
8440 return _abort("malformed")
8441 mime = _extract_data_url_mime(data_url)
8442 if mime is None:
8443 return _abort("decode")
8444 if mime not in _IMAGE_MIME_ALLOWED:
8445 return _abort("mime")
8446 try:
8447 saved = save_base64_data_url(
8448 data_url, media_dir, max_bytes=_MAX_IMAGE_BYTES,
8449 )
8450 except FileSizeExceeded:
8451 return _abort("size")
8452 except Exception as exc:
8453 logger.warning("websocket: media decode failed: {}", exc)
8454 return _abort("decode")
8455 if saved is None:
8456 return _abort("decode")
8457 paths.append(saved)
8458 return paths, None
8459
8460 async def _dispatch_envelope(
8461 self,
8462 connection: Any,
8463 client_id: str,
8464 envelope: dict[str, Any],
8465 ) -> None:
8466 """Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
8467 t = envelope.get("type")
8468 if t == "new_chat":
8469 new_id = str(uuid.uuid4())
8470 self._attach(connection, new_id)
8471 session_key = self._webui_session_key_for_connection(connection, new_id)
8472 source = self._envelope_source(envelope)
8473 if session_key and self._session_manager is not None:
8474 if source:
8475 self._persist_session_source(session_key, source)
8476 auto_generate = resolve_auto_generate_from_wire(envelope)
8477 if auto_generate is not None:
8478 session = self._session_manager.get_or_create(session_key)
8479 if apply_auto_generate(session.metadata, auto_generate):
8480 self._session_manager.save(session)
8481 await self._send_event(
8482 connection,
8483 "attached",
8484 chat_id=new_id,
8485 active_pe=self._active_pe_for_connection(connection, new_id),
8486 )
8487 return
8488 if t == "attach":
8489 cid = envelope.get("chat_id")
8490 if not _is_valid_chat_id(cid):
8491 await self._send_event(connection, "error", detail="invalid chat_id")
8492 return
8493 self._attach(connection, cid)
8494 session_key = self._webui_session_key_for_connection(connection, cid)
8495 source = self._envelope_source(envelope)
8496 if session_key and source:
8497 self._persist_session_source(session_key, source)
8498 await self._send_event(
8499 connection,
8500 "attached",
8501 chat_id=cid,
8502 active_pe=self._active_pe_for_connection(connection, cid),
8503 )
8504 return
8505 if t == "message":
8506 cid = envelope.get("chat_id")
8507 content = envelope.get("content")
8508 if not _is_valid_chat_id(cid):
8509 await self._send_event(connection, "error", detail="invalid chat_id")
8510 return
8511 if not isinstance(content, str):
8512 await self._send_event(connection, "error", detail="missing content")
8513 return
8514
8515 raw_media = envelope.get("media")
8516 media_paths: list[str] = []
8517 if raw_media is not None:
8518 if not isinstance(raw_media, list):
8519 await self._send_event(
8520 connection, "error",
8521 detail="image_rejected", reason="malformed",
8522 )
8523 return
8524 media_paths, reason = self._save_envelope_media(raw_media)
8525 if reason is not None:
8526 await self._send_event(
8527 connection, "error",
8528 detail="image_rejected", reason=reason,
8529 )
8530 return
8531
8532 # Allow image-only turns (content may be empty when media is attached).
8533 if not content.strip() and not media_paths:
8534 await self._send_event(connection, "error", detail="missing content")
8535 return
8536
8537 # Auto-attach on first use so clients can one-shot without a separate attach.
8538 self._attach(connection, cid)
8539 session_key = self._webui_session_key_for_connection(connection, cid)
8540 if session_key:
8541 self._hydrate_session_pe(session_key)
8542 source = self._envelope_source(envelope)
8543 if session_key and not source and self._session_manager is not None:
8544 from nanobot.session.source import get_source
8545
8546 existing = get_source(
8547 self._session_manager.get_or_create(session_key).metadata
8548 )
8549 if existing:
8550 source = existing
8551 if session_key and source:
8552 self._persist_session_source(session_key, source)
8553 msg_metadata: dict[str, Any] = {
8554 "remote": getattr(connection, "remote_address", None),
8555 }
8556 if source:
8557 msg_metadata["source"] = source
8558 n_shots = envelope.get("nShot")
8559 if n_shots is None:
8560 n_shots = envelope.get("nshot")
8561 if n_shots is not None and n_shots != "":
8562 msg_metadata["nShot"] = n_shots
8563 duration_sec = envelope.get("durationSec")
8564 if duration_sec is None:
8565 duration_sec = envelope.get("duration_sec")
8566 if duration_sec is not None and duration_sec != "":
8567 msg_metadata["duration_sec"] = duration_sec
8568 auto_generate = resolve_auto_generate_from_wire(envelope)
8569 if auto_generate is not None:
8570 msg_metadata["auto_generate"] = auto_generate
8571 for src, dst in (
8572 ("temperature", "temperature"),
8573 ("topP", "top_p"),
8574 ("top_p", "top_p"),
8575 ("topK", "top_k"),
8576 ("top_k", "top_k"),
8577 ):
8578 if src in envelope and envelope.get(src) not in (None, ""):
8579 msg_metadata[dst] = envelope.get(src)
8580 # 首帧参考图 — 从 WS 消息中提取并写入 session metadata
8581 ref = normalize_reference_image(envelope)
8582 if ref is None:
8583 ref_url = envelope.get("reference_image_url")
8584 ref_name = envelope.get("reference_image_name")
8585 ref_w = envelope.get("reference_image_width")
8586 ref_h = envelope.get("reference_image_height")
8587 if isinstance(ref_url, str) and ref_url.strip():
8588 ref = {
8589 "url": ref_url.strip(),
8590 "name": ref_name if isinstance(ref_name, str) else "",
8591 "width": int(ref_w) if isinstance(ref_w, (int, float)) else 0,
8592 "height": int(ref_h) if isinstance(ref_h, (int, float)) else 0,
8593 }
8594 if ref:
8595 msg_metadata["reference_image_url"] = ref["url"]
8596 msg_metadata["reference_image_name"] = ref.get("name") or ""
8597 msg_metadata["reference_image_width"] = ref.get("width") or 0
8598 msg_metadata["reference_image_height"] = ref.get("height") or 0
8599 if self._session_manager is not None and session_key and (
8600 n_shots is not None or duration_sec is not None or auto_generate is not None
8601 ):
8602 from nanobot.session.generation_settings import (
8603 apply_generation_settings,
8604 normalize_duration_sec,
8605 normalize_n_shots,
8606 )
8607
8608 session = self._session_manager.get_or_create(session_key)
8609 changed = False
8610 if auto_generate is not None:
8611 changed = apply_auto_generate(session.metadata, auto_generate) or changed
8612 if auto_generate is True:
8613 try:
8614 work_id = self._resolve_work_id_for_session(session_key)
8615 locked_n = None
8616 if work_id:
8617 locked_n = locked_shot_count_from_state(
8618 self._load_workplace_state(work_id)
8619 )
8620 self._sync_auto_generate_n_shots(
8621 session.metadata,
8622 duration_sec=duration_sec,
8623 locked_shot_count=locked_n,
8624 )
8625 changed = True
8626 except ValueError:
8627 logger.error(
8628 "websocket: invalid auto_generate duration_sec={} session_key={}",
8629 duration_sec,
8630 session_key,
8631 )
8632 elif n_shots is not None or duration_sec is not None:
8633 try:
8634 apply_generation_settings(
8635 session.metadata,
8636 n_shots=normalize_n_shots(n_shots),
8637 duration_sec=normalize_duration_sec(duration_sec),
8638 )
8639 changed = True
8640 except ValueError:
8641 pass
8642 if changed:
8643 self._session_manager.save(session)
8644 # 首帧参考图持久化到 session metadata(PUT 为真源,WS 仅兜底)
8645 if self._session_manager is not None and session_key and ref:
8646 session = self._session_manager.get_or_create(session_key)
8647 metadata = session.metadata if isinstance(session.metadata, dict) else {}
8648 if not is_reference_image_locked(metadata):
8649 work_id = self._resolve_work_id_for_session(session_key)
8650 state_locked = False
8651 if work_id:
8652 state = self._load_workplace_state(work_id)
8653 state_locked = is_reference_image_locked(state)
8654 if not state_locked:
8655 if is_blocked_local_url(str(ref.get("url") or "")):
8656 logger.error(
8657 "websocket: rejected local reference_image url session_key={}",
8658 session_key,
8659 )
8660 else:
8661 session.metadata["reference_image"] = ref
8662 self._session_manager.save(session)
8663 if self._session_manager is not None and session_key and any(
8664 key in msg_metadata for key in ("temperature", "top_p", "top_k")
8665 ):
8666 from nanobot.session.generation_settings import apply_llm_sampling_from_wire
8667
8668 session = self._session_manager.get_or_create(session_key)
8669 if apply_llm_sampling_from_wire(session.metadata, msg_metadata):
8670 self._session_manager.save(session)
8671 if self._session_manager is not None and session_key:
8672 from nanobot.session.reference_image_gate import (
8673 consume_skip_next_message,
8674 is_upload_gate_answer,
8675 )
8676
8677 session = self._session_manager.get_or_create(session_key)
8678 metadata = session.metadata if isinstance(session.metadata, dict) else {}
8679 user_answer = content
8680 skip_question = consume_skip_next_message(metadata)
8681 if skip_question:
8682 self._session_manager.save(session)
8683 self._persist_gate_user_reply(session_key, content)
8684 await self._emit_upload_gate_mismatch_card(
8685 session_key, cid, skip_question
8686 )
8687 return
8688 if is_upload_gate_answer(content):
8689 gate = self._commit_reference_image_gate(session_key, content)
8690 if gate is not None and gate.skip_agent and gate.mismatch_question:
8691 self._persist_gate_user_reply(session_key, content)
8692 await self._emit_upload_gate_mismatch_card(
8693 session_key, cid, gate.mismatch_question
8694 )
8695 return
8696 if gate is not None and gate.inject_note:
8697 content = f"{gate.inject_note}\n\n{content}"
8698 confirm_note = self._commit_story_direction_answer(
8699 session_key, user_answer
8700 )
8701 if confirm_note:
8702 content = f"{confirm_note}\n\n{content}"
8703 await self._handle_message(
8704 sender_id=client_id,
8705 chat_id=cid,
8706 content=content,
8707 media=media_paths or None,
8708 metadata=msg_metadata,
8709 session_key=session_key,
8710 )
8711 return
8712 if t == "workplace_merge_up":
8713 await self._dispatch_workplace_beat_merge_up(connection, envelope)
8714 return
8715 if t == "workplace_remove_shot":
8716 await self._dispatch_workplace_beat_remove_shot(connection, envelope)
8717 return
8718 if t == "workplace_split_shot":
8719 await self._dispatch_workplace_beat_split_shot(connection, envelope)
8720 return
8721 if t == "workplace_save_story":
8722 await self._dispatch_workplace_save_story(connection, envelope)
8723 return
8724 if t == "workplace_save_story_profile":
8725 await self._dispatch_workplace_save_story_profile(connection, envelope)
8726 return
8727 if t == "workplace_save_reference_image":
8728 await self._dispatch_workplace_save_reference_image(connection, envelope)
8729 return
8730 if t in {
8731 "workplace_save_memory_asset",
8732 "workplace_create_shot_memory_asset",
8733 "workplace_delete_memory_asset",
8734 "workplace_save_shot_memory_slots",
8735 }:
8736 await self._dispatch_workplace_memory_workspace(connection, envelope)
8737 return
8738 if t == "workplace_start_generation":
8739 await self._dispatch_workplace_start_generation(connection, envelope)
8740 return
8741 if t == "answer_question":
8742 await self._dispatch_answer_question(connection, envelope)
8743 return
8744 if t == "set_pe":
8745 await self._dispatch_set_pe(connection, envelope)
8746 return
8747 await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
8748
8749 def _active_pe_for_connection(self, connection: Any, chat_id: str) -> str:
8750 """Resolve the PE set active for a connection's session (per-session, else global)."""
8751 from nanobot.prompts import PEManager
8752
8753 manager = PEManager.instance()
8754 try:
8755 session_key = self._webui_session_key_for_connection(connection, chat_id)
8756 except Exception:
8757 return manager.active
8758 self._hydrate_session_pe(session_key)
8759 return manager.active_for_session(session_key)
8760
8761 async def _dispatch_set_pe(self, connection: Any, envelope: dict[str, Any]) -> None:
8762 """Bind the active PE set for the caller's session; notify only that chat's connections."""
8763 from nanobot.prompts import PEManager
8764
8765 name = envelope.get("name")
8766 if not isinstance(name, str) or not name:
8767 await self._send_event(connection, "error", detail="missing pe name")
8768 return
8769 chat_id = envelope.get("chat_id")
8770 if not _is_valid_chat_id(chat_id):
8771 await self._send_event(connection, "error", detail="invalid chat_id")
8772 return
8773 try:
8774 session_key = self._webui_session_key_for_connection(connection, chat_id)
8775 except Exception:
8776 await self._send_event(connection, "error", detail="session not found")
8777 return
8778 manager = PEManager.instance()
8779 if not manager.set_active_for_session(session_key, name):
8780 await self._send_event(connection, "error", detail=f"unknown pe set: {name!r}")
8781 return
8782 self._persist_session_pe(session_key, name)
8783 for conn, chats in list(self._conn_chats.items()):
8784 if chat_id in chats:
8785 await self._send_event(conn, "pe_updated", chat_id=chat_id, active=name)
8786
8787 async def _dispatch_answer_question(
8788 self,
8789 connection: Any,
8790 envelope: dict[str, Any],
8791 ) -> None:
8792 cid = envelope.get("chat_id")
8793 batch_id = envelope.get("question_batch_id")
8794 card_id = envelope.get("card_id")
8795 value = envelope.get("value")
8796 if not _is_valid_chat_id(cid):
8797 await self._send_event(connection, "error", detail="invalid chat_id")
8798 return
8799 if not isinstance(batch_id, str) or not batch_id.strip():
8800 await self._send_event(connection, "error", detail="missing question_batch_id")
8801 return
8802 if not isinstance(card_id, str) or not card_id.strip():
8803 await self._send_event(connection, "error", detail="missing card_id")
8804 return
8805 if not isinstance(value, str) or not value.strip():
8806 await self._send_event(connection, "error", detail="missing value")
8807 return
8808 if self._session_manager is None:
8809 await self._send_event(connection, "error", detail="session manager unavailable")
8810 return
8811
8812 session_key = self._webui_session_key_for_connection(connection, cid)
8813 if not session_key:
8814 await self._send_event(connection, "error", detail="session not found")
8815 return
8816
8817 ok = self._session_manager.record_question_answer(
8818 session_key,
8819 batch_id.strip(),
8820 card_id.strip(),
8821 value.strip(),
8822 )
8823 if not ok:
8824 await self._send_event(
8825 connection,
8826 "error",
8827 chat_id=cid,
8828 detail="question answer not found",
8829 )
8830 return
8831
8832 await self._send_event(
8833 connection,
8834 "question_answer_ok",
8835 chat_id=cid,
8836 question_batch_id=batch_id.strip(),
8837 card_id=card_id.strip(),
8838 value=value.strip(),
8839 )
8840 self._maybe_start_workflow_from_question_answer(session_key, value.strip())
8841 self._commit_reference_image_gate(session_key, value.strip())
8842 self._commit_story_direction_answer(session_key, value.strip())
8843
8844 def _commit_story_direction_answer(self, session_key: str, answer: str) -> str | None:
8845 from nanobot.agent.tools.ask_user import (
8846 is_reference_image_edit_option,
8847 is_story_confirm_option,
8848 )
8849
8850 if self._session_manager is None:
8851 return None
8852 if not is_story_confirm_option(answer) and not is_reference_image_edit_option(
8853 answer
8854 ):
8855 return None
8856 session = self._session_manager.get_or_create(session_key)
8857 if not isinstance(session.metadata, dict):
8858 session.metadata = {}
8859 note = apply_story_direction_answer(session.metadata, answer)
8860 self._session_manager.save(session)
8861 if note:
8862 logger.info(
8863 "story direction confirmed, rewrite suppressed session_key={}",
8864 session_key,
8865 )
8866 return note
8867
8868 def _maybe_start_workflow_from_question_answer(
8869 self, session_key: str, value: str
8870 ) -> None:
8871 answer = (value or "").strip()
8872 if answer not in {_CARD_ENTER_SHOT_POLISH, _CARD_CONFIRM_AUTO_GENERATE}:
8873 return
8874 try:
8875 if answer == _CARD_CONFIRM_AUTO_GENERATE:
8876 self._apply_workplace_auto_generate(session_key)
8877 else:
8878 self._apply_workplace_start_generation(session_key)
8879 except EchoGeneratorUnavailableError as exc:
8880 self._report_echo_unavailable(session_key, exc)
8881 return
8882 except EchoGeneratorBusyError as exc:
8883 logger.error(
8884 "workplace card workflow busy session_key={} answer={} error={}",
8885 session_key,
8886 answer,
8887 exc,
8888 )
8889 return
8890 except ValueError as exc:
8891 logger.error(
8892 "workplace card workflow rejected session_key={} answer={} error={}",
8893 session_key,
8894 answer,
8895 exc,
8896 )
8897 return
8898 except Exception:
8899 logger.opt(exception=True).error(
8900 "workplace card workflow failed session_key={} answer={}",
8901 session_key,
8902 answer,
8903 )
8904 return
8905 try:
8906 loop = asyncio.get_running_loop()
8907 loop.create_task(self._publish_workplace_update(session_key))
8908 except RuntimeError:
8909 pass
8910
8911 async def _send_workplace_action_error(
8912 self,
8913 connection: Any,
8914 *,
8915 chat_id: str,
8916 request_id: str,
8917 detail: str,
8918 ) -> None:
8919 await self._send_event(
8920 connection,
8921 "workplace_action_error",
8922 chat_id=chat_id,
8923 request_id=request_id,
8924 detail=detail,
8925 )
8926
8927 async def _send_workplace_action_ok(
8928 self,
8929 connection: Any,
8930 *,
8931 chat_id: str,
8932 request_id: str,
8933 work_id: str,
8934 workplace: dict[str, Any],
8935 ) -> None:
8936 await self._send_event(
8937 connection,
8938 "workplace_action_ok",
8939 chat_id=chat_id,
8940 request_id=request_id,
8941 work_id=work_id,
8942 workplace=workplace,
8943 )
8944
8945 async def _dispatch_workplace_beat_merge_up(
8946 self,
8947 connection: Any,
8948 envelope: dict[str, Any],
8949 ) -> None:
8950 cid = envelope.get("chat_id")
8951 request_id = envelope.get("request_id")
8952 shot_raw = envelope.get("shot_id")
8953 if not _is_valid_chat_id(cid):
8954 await self._send_workplace_action_error(
8955 connection,
8956 chat_id="",
8957 request_id=str(request_id or ""),
8958 detail="invalid chat_id",
8959 )
8960 return
8961 if not isinstance(request_id, str) or not request_id.strip():
8962 await self._send_event(connection, "error", detail="missing request_id")
8963 return
8964 try:
8965 shot_id = int(shot_raw)
8966 except (TypeError, ValueError):
8967 await self._send_workplace_action_error(
8968 connection,
8969 chat_id=cid,
8970 request_id=request_id,
8971 detail="invalid shot_id",
8972 )
8973 return
8974 try:
8975 session_key = self._webui_session_key_for_connection(connection, cid)
8976 except ValueError as exc:
8977 await self._send_workplace_action_error(
8978 connection,
8979 chat_id=cid,
8980 request_id=request_id,
8981 detail=str(exc),
8982 )
8983 return
8984 merged_text = envelope.get("merged_text")
8985 if merged_text is not None and not isinstance(merged_text, str):
8986 merged_text = None
8987 try:
8988 merged = self._apply_workplace_shot_merge_up(
8989 session_key,
8990 shot_id,
8991 merged_text=merged_text,
8992 )
8993 except ValueError as exc:
8994 await self._send_workplace_action_error(
8995 connection,
8996 chat_id=cid,
8997 request_id=request_id,
8998 detail=str(exc),
8999 )
9000 return
9001 if merged is None:
9002 await self._send_workplace_action_error(
9003 connection,
9004 chat_id=cid,
9005 request_id=request_id,
9006 detail="beat not found",
9007 )
9008 return
9009 work_id, workplace = merged
9010 self._attach(connection, cid)
9011 await self._send_workplace_action_ok(
9012 connection,
9013 chat_id=cid,
9014 request_id=request_id,
9015 work_id=work_id,
9016 workplace=workplace,
9017 )
9018
9019 async def _dispatch_workplace_beat_remove_shot(
9020 self,
9021 connection: Any,
9022 envelope: dict[str, Any],
9023 ) -> None:
9024 cid = envelope.get("chat_id")
9025 request_id = envelope.get("request_id")
9026 shot_raw = envelope.get("shot_id")
9027 if not _is_valid_chat_id(cid):
9028 await self._send_workplace_action_error(
9029 connection,
9030 chat_id="",
9031 request_id=str(request_id or ""),
9032 detail="invalid chat_id",
9033 )
9034 return
9035 if not isinstance(request_id, str) or not request_id.strip():
9036 await self._send_event(connection, "error", detail="missing request_id")
9037 return
9038 try:
9039 shot_id = int(shot_raw)
9040 except (TypeError, ValueError):
9041 await self._send_workplace_action_error(
9042 connection,
9043 chat_id=cid,
9044 request_id=request_id,
9045 detail="invalid shot_id",
9046 )
9047 return
9048 try:
9049 session_key = self._webui_session_key_for_connection(connection, cid)
9050 except ValueError as exc:
9051 await self._send_workplace_action_error(
9052 connection,
9053 chat_id=cid,
9054 request_id=request_id,
9055 detail=str(exc),
9056 )
9057 return
9058 try:
9059 removed = self._apply_workplace_shot_remove(session_key, shot_id)
9060 except ValueError as exc:
9061 await self._send_workplace_action_error(
9062 connection,
9063 chat_id=cid,
9064 request_id=request_id,
9065 detail=str(exc),
9066 )
9067 return
9068 if removed is None:
9069 await self._send_workplace_action_error(
9070 connection,
9071 chat_id=cid,
9072 request_id=request_id,
9073 detail="beat not found",
9074 )
9075 return
9076 work_id, workplace = removed
9077 self._attach(connection, cid)
9078 await self._send_workplace_action_ok(
9079 connection,
9080 chat_id=cid,
9081 request_id=request_id,
9082 work_id=work_id,
9083 workplace=workplace,
9084 )
9085
9086 async def _dispatch_workplace_beat_split_shot(
9087 self,
9088 connection: Any,
9089 envelope: dict[str, Any],
9090 ) -> None:
9091 cid = envelope.get("chat_id")
9092 request_id = envelope.get("request_id")
9093 shot_raw = envelope.get("shot_id")
9094 if not _is_valid_chat_id(cid):
9095 await self._send_workplace_action_error(
9096 connection,
9097 chat_id="",
9098 request_id=str(request_id or ""),
9099 detail="invalid chat_id",
9100 )
9101 return
9102 if not isinstance(request_id, str) or not request_id.strip():
9103 await self._send_event(connection, "error", detail="missing request_id")
9104 return
9105 try:
9106 shot_id = int(shot_raw)
9107 except (TypeError, ValueError):
9108 await self._send_workplace_action_error(
9109 connection,
9110 chat_id=cid,
9111 request_id=request_id,
9112 detail="invalid shot_id",
9113 )
9114 return
9115 split_payload: dict[str, Any] = {}
9116 if "cursor_pos" in envelope:
9117 try:
9118 split_payload["cursor_pos"] = int(envelope["cursor_pos"])
9119 except (TypeError, ValueError):
9120 await self._send_workplace_action_error(
9121 connection,
9122 chat_id=cid,
9123 request_id=request_id,
9124 detail="cursor_pos must be an integer",
9125 )
9126 return
9127 elif "before_text" in envelope and "after_text" in envelope:
9128 split_payload["before_text"] = str(envelope.get("before_text") or "")
9129 split_payload["after_text"] = str(envelope.get("after_text") or "")
9130 else:
9131 await self._send_workplace_action_error(
9132 connection,
9133 chat_id=cid,
9134 request_id=request_id,
9135 detail="cursor_pos or before_text/after_text required",
9136 )
9137 return
9138 try:
9139 session_key = self._webui_session_key_for_connection(connection, cid)
9140 except ValueError as exc:
9141 await self._send_workplace_action_error(
9142 connection,
9143 chat_id=cid,
9144 request_id=request_id,
9145 detail=str(exc),
9146 )
9147 return
9148 try:
9149 split_result = self._apply_workplace_shot_split_shot(
9150 session_key,
9151 shot_id,
9152 split_payload=split_payload,
9153 )
9154 except ValueError as exc:
9155 await self._send_workplace_action_error(
9156 connection,
9157 chat_id=cid,
9158 request_id=request_id,
9159 detail=str(exc),
9160 )
9161 return
9162 if split_result is None:
9163 await self._send_workplace_action_error(
9164 connection,
9165 chat_id=cid,
9166 request_id=request_id,
9167 detail="beat not found",
9168 )
9169 return
9170 work_id, workplace = split_result
9171 self._attach(connection, cid)
9172 await self._send_workplace_action_ok(
9173 connection,
9174 chat_id=cid,
9175 request_id=request_id,
9176 work_id=work_id,
9177 workplace=workplace,
9178 )
9179
9180 async def _dispatch_workplace_save_story(
9181 self,
9182 connection: Any,
9183 envelope: dict[str, Any],
9184 ) -> None:
9185 cid = envelope.get("chat_id")
9186 request_id = envelope.get("request_id")
9187 story_md = envelope.get("story_md")
9188 if not _is_valid_chat_id(cid):
9189 await self._send_workplace_action_error(
9190 connection,
9191 chat_id="",
9192 request_id=str(request_id or ""),
9193 detail="invalid chat_id",
9194 )
9195 return
9196 if not isinstance(request_id, str) or not request_id.strip():
9197 await self._send_event(connection, "error", detail="missing request_id")
9198 return
9199 if not isinstance(story_md, str):
9200 await self._send_workplace_action_error(
9201 connection,
9202 chat_id=cid,
9203 request_id=request_id,
9204 detail="story_md is required",
9205 )
9206 return
9207 try:
9208 session_key = self._webui_session_key_for_connection(connection, cid)
9209 except ValueError as exc:
9210 await self._send_workplace_action_error(
9211 connection,
9212 chat_id=cid,
9213 request_id=request_id,
9214 detail=str(exc),
9215 )
9216 return
9217 try:
9218 work_id, workplace = self._apply_workplace_story_save(session_key, story_md)
9219 except ValueError as exc:
9220 await self._send_workplace_action_error(
9221 connection,
9222 chat_id=cid,
9223 request_id=request_id,
9224 detail=str(exc),
9225 )
9226 return
9227 self._attach(connection, cid)
9228 await self._send_workplace_action_ok(
9229 connection,
9230 chat_id=cid,
9231 request_id=request_id,
9232 work_id=work_id,
9233 workplace=workplace,
9234 )
9235
9236 async def _dispatch_workplace_save_story_profile(
9237 self,
9238 connection: Any,
9239 envelope: dict[str, Any],
9240 ) -> None:
9241 cid = envelope.get("chat_id")
9242 request_id = envelope.get("request_id")
9243 story_profile = envelope.get("story_profile")
9244 if not _is_valid_chat_id(cid):
9245 await self._send_workplace_action_error(
9246 connection,
9247 chat_id="",
9248 request_id=str(request_id or ""),
9249 detail="invalid chat_id",
9250 )
9251 return
9252 if not isinstance(request_id, str) or not request_id.strip():
9253 await self._send_event(connection, "error", detail="missing request_id")
9254 return
9255 if not isinstance(story_profile, dict):
9256 await self._send_workplace_action_error(
9257 connection,
9258 chat_id=cid,
9259 request_id=request_id,
9260 detail="story_profile is required",
9261 )
9262 return
9263 logger.info(
9264 "workplace WS story-profile/save chat_id={} request_id={}",
9265 cid,
9266 request_id,
9267 )
9268 try:
9269 session_key = self._webui_session_key_for_connection(connection, cid)
9270 except ValueError as exc:
9271 await self._send_workplace_action_error(
9272 connection,
9273 chat_id=cid,
9274 request_id=request_id,
9275 detail=str(exc),
9276 )
9277 return
9278 try:
9279 work_id, workplace = self._apply_workplace_story_profile_save(
9280 session_key,
9281 story_profile,
9282 )
9283 except ValueError as exc:
9284 await self._send_workplace_action_error(
9285 connection,
9286 chat_id=cid,
9287 request_id=request_id,
9288 detail=str(exc),
9289 )
9290 return
9291 self._attach(connection, cid)
9292 await self._send_workplace_action_ok(
9293 connection,
9294 chat_id=cid,
9295 request_id=request_id,
9296 work_id=work_id,
9297 workplace=workplace,
9298 )
9299
9300 def _schedule_workplace_memory_recommendation_instruction(
9301 self,
9302 session_key: str,
9303 *,
9304 work_id: str,
9305 shot_id: int,
9306 ) -> None:
9307 content = (
9308 "Internal Memory Workspace task. Execute silently.\n\n"
9309 f"Director work_id: `{work_id}`\n"
9310 f"Target shot_id: `{shot_id}`\n\n"
9311 "1. Call `get_workplace_status(include_shots=true, include_jobs=false)` and "
9312 "`get_shot` for the target shot.\n"
9313 "2. Read only `memory_assets` entries supplied by the status tool; do not inspect binary files.\n"
9314 "3. Recommend zero to seven ordered slots for the target shot based on its caption, "
9315 "provenance, `reference_type`, `reference_label`, `identity_ids`, and `profile_text`. "
9316 "Treat character, scene, style, object, and other references according to their assigned role.\n"
9317 "4. Call `set_shot_memory_recommendations` exactly once. Use only listed asset IDs.\n\n"
9318 "Assets without a text profile are intentionally invisible and must not be inferred. "
9319 "This is a recommendation draft only: never generate the shot and never approve slots. "
9320 "Do not send a user-facing message."
9321 )
9322 self._schedule_workplace_workflow_instruction(
9323 session_key,
9324 work_id=work_id,
9325 injected_event="workplace_memory_recommendation",
9326 content=content,
9327 )
9328
9329 async def _dispatch_workplace_save_reference_image(
9330 self,
9331 connection: Any,
9332 envelope: dict[str, Any],
9333 ) -> None:
9334 """Persist a first-frame image carried in a WebSocket frame.
9335
9336 Image data URLs are intentionally sent over WebSocket. Encoding them in
9337 ``X-Nanobot-Body`` exceeds common HTTP header limits and causes 431s.
9338 """
9339 cid = envelope.get("chat_id")
9340 request_id = envelope.get("request_id")
9341 if not _is_valid_chat_id(cid):
9342 await self._send_workplace_action_error(
9343 connection,
9344 chat_id="",
9345 request_id=str(request_id or ""),
9346 detail="invalid chat_id",
9347 )
9348 return
9349 if not isinstance(request_id, str) or not request_id.strip():
9350 await self._send_event(connection, "error", detail="missing request_id")
9351 return
9352 ref = normalize_reference_image(envelope.get("image"))
9353 if ref is None:
9354 await self._send_workplace_action_error(
9355 connection,
9356 chat_id=cid,
9357 request_id=request_id,
9358 detail="reference image url is required",
9359 )
9360 return
9361 url = str(ref.get("url") or "")
9362 if url.startswith("data:image/"):
9363 paths, reason = self._save_envelope_media(
9364 [{"data_url": url, "name": ref.get("name") or "first-frame.jpg"}]
9365 )
9366 if reason is not None or not paths:
9367 await self._send_workplace_action_error(
9368 connection,
9369 chat_id=cid,
9370 request_id=request_id,
9371 detail=f"image_rejected:{reason or 'decode'}",
9372 )
9373 return
9374 # Decoding above validates MIME and size. Keep the data URL in
9375 # metadata so the VLM can consume it without a localhost fetch.
9376 for path in paths:
9377 try:
9378 Path(path).unlink(missing_ok=True)
9379 except OSError:
9380 pass
9381 else:
9382 if is_blocked_local_url(url):
9383 await self._send_workplace_action_error(
9384 connection,
9385 chat_id=cid,
9386 request_id=request_id,
9387 detail="url must be a public HTTP(S) address",
9388 )
9389 return
9390 try:
9391 configure_download_policy(self._download_url_policy())
9392 validate_external_url(url)
9393 except UrlValidationError as exc:
9394 await self._send_workplace_action_error(
9395 connection,
9396 chat_id=cid,
9397 request_id=request_id,
9398 detail=str(exc),
9399 )
9400 return
9401 try:
9402 session_key = self._webui_session_key_for_connection(connection, cid)
9403 workplace = self._persist_reference_image(session_key, ref)
9404 except (PermissionError, ValueError) as exc:
9405 await self._send_workplace_action_error(
9406 connection,
9407 chat_id=cid,
9408 request_id=request_id,
9409 detail=str(exc),
9410 )
9411 return
9412 self._attach(connection, cid)
9413 await self._send_workplace_action_ok(
9414 connection,
9415 chat_id=cid,
9416 request_id=request_id,
9417 work_id=self._resolve_work_id_for_session(session_key) or "",
9418 workplace=workplace,
9419 )
9420
9421 async def _dispatch_workplace_memory_workspace(
9422 self,
9423 connection: Any,
9424 envelope: dict[str, Any],
9425 ) -> None:
9426 """Persist local assets or apply an ordered Memory slot draft."""
9427 cid = envelope.get("chat_id")
9428 request_id = envelope.get("request_id")
9429 if not _is_valid_chat_id(cid):
9430 await self._send_workplace_action_error(
9431 connection,
9432 chat_id="",
9433 request_id=str(request_id or ""),
9434 detail="invalid chat_id",
9435 )
9436 return
9437 if not isinstance(request_id, str) or not request_id.strip():
9438 await self._send_event(connection, "error", detail="missing request_id")
9439 return
9440 try:
9441 session_key = self._webui_session_key_for_connection(connection, cid)
9442 action = envelope.get("type")
9443 if action == "workplace_save_memory_asset":
9444 work_id, workplace = await asyncio.to_thread(
9445 self._apply_workplace_memory_asset_save,
9446 session_key,
9447 envelope.get("asset"),
9448 )
9449 elif action == "workplace_create_shot_memory_asset":
9450 try:
9451 shot_id = int(envelope.get("shot_id") or 0)
9452 except (TypeError, ValueError) as exc:
9453 raise ValueError("shot_id is invalid") from exc
9454 if shot_id <= 0:
9455 raise ValueError("shot_id is invalid")
9456 work_id, workplace = await asyncio.to_thread(
9457 self._apply_workplace_shot_memory_asset_create,
9458 session_key,
9459 shot_id,
9460 envelope.get("asset"),
9461 )
9462 elif action == "workplace_delete_memory_asset":
9463 asset_id = str(envelope.get("asset_id") or "").strip()
9464 if not asset_id:
9465 raise ValueError("asset_id is required")
9466 work_id, workplace = self._apply_workplace_memory_asset_delete(
9467 session_key,
9468 asset_id,
9469 )
9470 else:
9471 try:
9472 shot_id = int(envelope.get("shot_id") or 0)
9473 except (TypeError, ValueError) as exc:
9474 raise ValueError("shot_id is invalid") from exc
9475 if shot_id <= 0:
9476 raise ValueError("shot_id is invalid")
9477 work_id, workplace = self._apply_workplace_shot_memory_slots_save(
9478 session_key,
9479 shot_id,
9480 envelope.get("slots"),
9481 )
9482 except (PermissionError, RuntimeError, ValueError) as exc:
9483 await self._send_workplace_action_error(
9484 connection,
9485 chat_id=cid,
9486 request_id=request_id,
9487 detail=str(exc),
9488 )
9489 return
9490 self._attach(connection, cid)
9491 await self._send_workplace_action_ok(
9492 connection,
9493 chat_id=cid,
9494 request_id=request_id,
9495 work_id=work_id,
9496 workplace=workplace,
9497 )
9498
9499 async def _dispatch_workplace_start_generation(
9500 self,
9501 connection: Any,
9502 envelope: dict[str, Any],
9503 ) -> None:
9504 cid = envelope.get("chat_id")
9505 request_id = envelope.get("request_id")
9506 if not _is_valid_chat_id(cid):
9507 await self._send_workplace_action_error(
9508 connection,
9509 chat_id="",
9510 request_id=str(request_id or ""),
9511 detail="invalid chat_id",
9512 )
9513 return
9514 if not isinstance(request_id, str) or not request_id.strip():
9515 await self._send_event(connection, "error", detail="missing request_id")
9516 return
9517 logger.info(
9518 "workplace WS workflow/start-generation chat_id={} request_id={}",
9519 cid,
9520 request_id,
9521 )
9522 try:
9523 session_key = self._webui_session_key_for_connection(connection, cid)
9524 except ValueError as exc:
9525 await self._send_workplace_action_error(
9526 connection,
9527 chat_id=cid,
9528 request_id=request_id,
9529 detail=str(exc),
9530 )
9531 return
9532 try:
9533 work_id, workplace = self._apply_workplace_start_generation(session_key)
9534 except ValueError as exc:
9535 await self._send_workplace_action_error(
9536 connection,
9537 chat_id=cid,
9538 request_id=request_id,
9539 detail=str(exc),
9540 )
9541 return
9542 self._attach(connection, cid)
9543 await self._send_workplace_action_ok(
9544 connection,
9545 chat_id=cid,
9546 request_id=request_id,
9547 work_id=work_id,
9548 workplace=workplace,
9549 )
9550
9551 async def stop(self) -> None:
9552 if not self._running:
9553 return
9554 self._running = False
9555 if self._stop_event:
9556 self._stop_event.set()
9557 if self._server_task:
9558 try:
9559 await self._server_task
9560 except Exception as e:
9561 logger.warning("websocket: server task error during shutdown: {}", e)
9562 self._server_task = None
9563 self._subs.clear()
9564 self._conn_chats.clear()
9565 self._conn_default.clear()
9566 self._issued_tokens.clear()
9567 self._api_tokens.clear()
9568 self._loop = None
9569
9570 async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
9571 """Send a raw frame to one connection, cleaning up on ConnectionClosed."""
9572 try:
9573 await connection.send(raw)
9574 except ConnectionClosed:
9575 self._cleanup_connection(connection)
9576 logger.warning("websocket{}connection gone", label)
9577 except Exception as e:
9578 logger.error("websocket{}send failed: {}", label, e)
9579 raise
9580
9581 async def send(self, msg: OutboundMessage) -> None:
9582 workplace_event = msg.metadata.get("_workplace_event")
9583 session_key = (
9584 msg.metadata.get("session_key")
9585 or self._webui_session_key_for_chat(msg.chat_id)
9586 )
9587 if workplace_event == "updated" and isinstance(session_key, str) and session_key:
9588 self._schedule_auto_generate_continue(session_key)
9589 # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
9590 conns = list(self._subs.get(msg.chat_id, ()))
9591 if not conns:
9592 logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id)
9593 return
9594 workplace_event = msg.metadata.get("_workplace_event")
9595 if workplace_event == "updated":
9596 payload: dict[str, Any] = {
9597 "event": "workplace_updated",
9598 "chat_id": msg.chat_id,
9599 }
9600 workplace_payload = None
9601 if (
9602 isinstance(session_key, str)
9603 and session_key
9604 and self._session_manager is not None
9605 ):
9606 workplace_payload = self._build_workplace_payload(session_key)
9607 elif isinstance(msg.metadata.get("workplace"), dict):
9608 workplace_payload = msg.metadata.get("workplace")
9609 if isinstance(workplace_payload, dict):
9610 payload["workplace"] = workplace_payload
9611 work_id = msg.metadata.get("work_id")
9612 if not isinstance(work_id, str) or not work_id.strip():
9613 work_id = workplace_payload.get("work_id") if isinstance(workplace_payload, dict) else None
9614 if isinstance(work_id, str) and work_id.strip():
9615 payload["work_id"] = work_id.strip()
9616 else:
9617 payload = {
9618 "event": "message",
9619 "chat_id": msg.chat_id,
9620 "text": msg.content,
9621 }
9622 if msg.media:
9623 payload["media"] = msg.media
9624 media_urls = [
9625 ref
9626 for ref in (self._public_media_entry(entry) for entry in msg.media)
9627 if ref is not None
9628 ]
9629 if media_urls:
9630 payload["media_urls"] = media_urls
9631 if msg.reply_to:
9632 payload["reply_to"] = msg.reply_to
9633 # Mark intermediate agent breadcrumbs (tool-call hints, generic
9634 # progress strings) so WS clients can render them as subordinate
9635 # trace rows rather than conversational replies.
9636 if msg.metadata.get("_tool_hint"):
9637 payload["kind"] = "tool_hint"
9638 elif msg.metadata.get("_progress"):
9639 payload["kind"] = "progress"
9640 questions = msg.metadata.get("questions")
9641 if isinstance(questions, list) and questions:
9642 payload["questions"] = questions
9643 batch_id = msg.metadata.get("question_batch_id")
9644 if isinstance(batch_id, str) and batch_id.strip():
9645 payload["question_batch_id"] = batch_id.strip()
9646 raw = json.dumps(payload, ensure_ascii=False)
9647 for connection in conns:
9648 await self._safe_send_to(connection, raw, label=" ")
9649
9650 async def send_delta(
9651 self,
9652 chat_id: str,
9653 delta: str,
9654 metadata: dict[str, Any] | None = None,
9655 ) -> None:
9656 conns = list(self._subs.get(chat_id, ()))
9657 if not conns:
9658 return
9659 meta = metadata or {}
9660 if meta.get("_stream_end"):
9661 body: dict[str, Any] = {
9662 "event": "stream_end",
9663 "chat_id": chat_id,
9664 "resuming": bool(meta.get("_resuming")),
9665 }
9666 else:
9667 body = {
9668 "event": "delta",
9669 "chat_id": chat_id,
9670 "text": delta,
9671 }
9672 if meta.get("_stream_id") is not None:
9673 body["stream_id"] = meta["_stream_id"]
9674 raw = json.dumps(body, ensure_ascii=False)
9675 for connection in conns:
9676 await self._safe_send_to(connection, raw, label=" stream ")
9677
9677 lines PYTHON