fix: compose named cockpit modes from capabilities

This commit is contained in:
vee
2026-09-07 20:52:19 +08:00
parent cf9b89508c
commit 6b2b644ee6
23 changed files with 2917 additions and 124 deletions
+142 -26
View File
@@ -7,6 +7,7 @@ import json
import re
from dataclasses import dataclass
from pathlib import Path
from threading import RLock
from typing import Any
import yaml
@@ -419,26 +420,28 @@ class ModeProfileRegistry:
self.user_root = Path(user_root) if user_root is not None else self.profile_root
self.session_user_profiles = user_root is not None
self._profiles: list[ModeProfile] = []
self._write_lock = RLock()
self.reload()
def reload(self) -> None:
profiles: list[ModeProfile] = []
if self.profile_root.exists():
for path in sorted(self.profile_root.rglob("*.yaml")):
if not path.is_file():
continue
if self.session_user_profiles and _is_user_profile_path(
path, self.profile_root
):
continue
profiles.append(ModeProfile.from_yaml(path, self.profile_root))
if self.session_user_profiles and self.user_root.exists():
profiles.extend(
ModeProfile.from_yaml(path, self.user_root)
for path in sorted(self.user_root.rglob("*.yaml"))
if path.is_file()
)
self._profiles = profiles
with self._write_lock:
profiles: list[ModeProfile] = []
if self.profile_root.exists():
for path in sorted(self.profile_root.rglob("*.yaml")):
if not path.is_file():
continue
if self.session_user_profiles and _is_user_profile_path(
path, self.profile_root
):
continue
profiles.append(ModeProfile.from_yaml(path, self.profile_root))
if self.session_user_profiles and self.user_root.exists():
profiles.extend(
ModeProfile.from_yaml(path, self.user_root)
for path in sorted(self.user_root.rglob("*.yaml"))
if path.is_file()
)
self._profiles = profiles
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
@@ -505,6 +508,61 @@ class ModeProfileRegistry:
matches.append(selected[profile.id])
return matches
def resolve_exact_name(
self,
value: str,
vehicle_id: str | None = None,
) -> ModeProfile | None:
"""Resolve one visible mode by an exact ID, name, or alias.
Scene creation must not use the substring semantics of :meth:`match_text`:
a name can contain another mode alias and accidentally combine two profiles.
Returning ``None`` for both no match and an ambiguous alias keeps that path
fail-closed so callers can choose an explicit fallback without combining modes.
"""
needle = _normalize_match_text(str(value).strip())
if not needle:
return None
matches = [
profile
for profile in self._profiles_for_vehicle(vehicle_id)
if needle
in {
_normalize_match_text(str(candidate))
for candidate in (profile.id, profile.name, *profile.aliases)
if str(candidate).strip()
}
]
return matches[0] if len(matches) == 1 else None
@staticmethod
def profile_revision(profile: ModeProfile) -> str:
"""Return a stable semantic revision for optimistic mode updates."""
document = {
"id": profile.id,
"name": profile.name,
"aliases": list(profile.aliases),
"description": profile.description,
"icon": profile.icon,
"scope": {
"common": profile.common,
"vehicle_ids": list(profile.vehicle_ids),
},
"operations": [operation.to_yaml() for operation in profile.operations],
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
"instruction": profile.instruction,
"command_flow": profile.command_flow,
}
canonical = json.dumps(
document,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@staticmethod
def requests_for_profiles(profiles: list[ModeProfile]) -> list[dict[str, Any]]:
return [
@@ -526,6 +584,8 @@ class ModeProfileRegistry:
instruction: str = "",
command_flow: dict[str, Any] | None = None,
timeline: ModeTimeline | None = None,
expected_revision: str | None = None,
create_only: bool = False,
) -> ModeProfile:
clean_name = _clean_name(name)
canonical_operations = [
@@ -566,20 +626,64 @@ class ModeProfileRegistry:
if vehicle_id
else self.user_root / "common" / "user" / f"{profile_id}.yaml"
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.user_root)
with self._write_lock:
# Refresh inside the same critical section as the compare-and-swap.
# This makes concurrent assistant saves one-winner within the server
# process instead of letting two stale previews overwrite each other.
self.reload()
visible_profiles = self._profiles_for_vehicle(vehicle_id)
current = next(
(profile for profile in visible_profiles if profile.id == profile_id),
None,
)
normalized_name = _normalize_match_text(clean_name)
name_collision = any(
profile.id != profile_id
and normalized_name
in {
_normalize_match_text(str(candidate))
for candidate in (profile.id, profile.name, *profile.aliases)
if str(candidate).strip()
}
for profile in visible_profiles
)
if name_collision:
raise ValueError("该模式名称已被其他模式使用。")
if create_only and current is not None:
raise ValueError("该模式已由其他会话创建,请刷新后重试。")
if expected_revision is not None:
expected = str(expected_revision).strip()
if (
not expected
or current is None
or self.profile_revision(current) != expected
):
raise ValueError(
"功能模式已被其他会话更新,请重新生成预览后再保存。"
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.user_root)
def delete_user_mode(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> ModeProfile:
with self._write_lock:
return self._delete_user_mode_locked(mode_id, vehicle_id)
def _delete_user_mode_locked(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> ModeProfile:
clean_id = str(mode_id).strip()
if not clean_id:
@@ -623,6 +727,14 @@ class ModeProfileRegistry:
self,
profile: ModeProfile,
operations: list[dict[str, Any] | ModeOperation],
) -> ModeProfile:
with self._write_lock:
return self._update_mode_locked(profile, operations)
def _update_mode_locked(
self,
profile: ModeProfile,
operations: list[dict[str, Any] | ModeOperation],
) -> ModeProfile:
canonical_updates = _canonical_operations(operations)
if not canonical_updates:
@@ -642,6 +754,10 @@ class ModeProfileRegistry:
return ModeProfile.from_yaml(path, self._root_for_path(path))
def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile:
with self._write_lock:
return self._restore_initial_mode_locked(profile)
def _restore_initial_mode_locked(self, profile: ModeProfile) -> ModeProfile:
path = Path(profile.source)
backup_path = _initial_backup_path(path)
if not backup_path.is_file():