diff --git a/config/version.py b/config/version.py new file mode 100644 index 00000000..b8c380c9 --- /dev/null +++ b/config/version.py @@ -0,0 +1,8 @@ +"""应用版本号。 + +用于对外 HTTP 请求的 User-Agent 标识(如 ``Astrion/1.0``)。 +OpenCode 等供应商要求客户端工具以「产品名/版本号」格式自标识, +禁止使用宽泛 UA(如 python-httpx/x.y)。 +""" + +APP_VERSION = "1.0" diff --git a/core/main_terminal.py b/core/main_terminal.py index 44614a8d..d7f25208 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -102,6 +102,10 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi # 初始化组件 self.api_client = APIClient(thinking_mode=self.thinking_mode) self.api_client.project_path = project_path + # 外部会话标识(x-opencode-session):请求时按当前对话惰性解析, + # 仅个人空间开关开启且端点为 opencode.ai 时附加;对话 ID 在请求时 + # 从 context_manager 读取(构造时对话尚未创建/加载)。 + self.api_client.extra_headers_resolver = self._resolve_external_session_headers self.model_key = get_default_model_key() self.model_profile = get_model_profile(self.model_key) self.apply_model_profile(self.model_profile) @@ -245,6 +249,30 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi "save": self.save_conversation_command } + def _resolve_external_session_headers(self, base_url: Optional[str]) -> Dict[str, str]: + """APIClient extra_headers_resolver:为当前对话解析 x-opencode-session 头。 + + 请求时惰性执行:个人空间开关开启且端点为 opencode.ai 时,返回该对话 + 的稳定 session ID(首次请求生成并存入对话 metadata,深压缩后重置)。 + 任何异常返回空 dict,不影响主请求。 + """ + try: + from modules.external_session import resolve_conversation_headers + + cm = getattr(self, "context_manager", None) + conversation_id = getattr(cm, "current_conversation_id", None) if cm else None + if not conversation_id: + return {} + manager = cm._get_conversation_manager_for_id(conversation_id) + return resolve_conversation_headers( + base_url, + conversation_id, + manager=manager, + base_dir=getattr(self, "data_dir", None), + ) + except Exception: + return {} + def _apply_container_session(self, session: Optional["ContainerHandle"]): self.container_session = session if session and session.mode == "docker": diff --git a/modules/approval_agent.py b/modules/approval_agent.py index 6cdb81a5..08719e14 100644 --- a/modules/approval_agent.py +++ b/modules/approval_agent.py @@ -9,6 +9,7 @@ from typing import Any, Callable, Dict, List, Optional import httpx from config import LOGS_DIR +from modules.external_session import build_user_agent, resolve_ephemeral_headers from modules.review_agent_config import resolve_review_agent_config from modules.i18n import tr @@ -104,7 +105,9 @@ class ApprovalAgent: _flush_trace(out) return out endpoint = f"{url.rstrip('/')}/chat/completions" - headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + # 以「产品名/版本号」自标识;审核调用无对话连续性,每次审核生成一次性 session ID + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": build_user_agent()} + headers.update(resolve_ephemeral_headers(url)) max_rounds = int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS) timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) extra_params = dict(self.cfg.get("extra_params") or {}) diff --git a/modules/external_session.py b/modules/external_session.py new file mode 100644 index 00000000..c5729e28 --- /dev/null +++ b/modules/external_session.py @@ -0,0 +1,119 @@ +"""外部会话标识(x-opencode-session)管理。 + +OpenCode Go/Zen 自 2026-09-05 起要求发往其端点的请求携带 +``x-opencode-session`` 请求头:同一对话使用稳定 ID,供其做会话亲和 +路由与 prompt 缓存优化(https://opencode.ai/docs/go/)。 + +设计约定: +- 开关:个人空间「模型与思考」-> ``external_session_header``,默认关闭(opt-in)。 +- ID 取值:随机 uuid4 hex,不复用 conversation_id(避免向 vendor 暴露本地标识)。 +- 生命周期:对话首次请求时惰性生成并存入对话 metadata;深压缩完成后重置 + (压缩重写上下文后,旧 session 的缓存亲和已失去意义,语义上等同新 session)。 +- 下发范围:仅对 opencode.ai 域名下发,不向其他 provider 泄露对话标识。 +""" + +from __future__ import annotations + +import uuid +from typing import Any, Dict, Optional + +from config.version import APP_VERSION + +OPENCODE_HOST_MARKER = "opencode.ai" +SESSION_METADATA_KEY = "external_session_id" + + +def build_user_agent() -> str: + """对外请求的 User-Agent(OpenCode 要求工具自标识,禁止宽泛 UA)。""" + return f"Astrion/{APP_VERSION}" + + +def new_external_session_id() -> str: + """生成新的随机外部会话 ID。""" + return uuid.uuid4().hex + + +def is_opencode_endpoint(base_url: Optional[str]) -> bool: + """判定 base_url 是否指向 opencode.ai(Go 与 Zen 同域名)。""" + return bool(base_url) and OPENCODE_HOST_MARKER in str(base_url).lower() + + +def external_session_header_enabled(base_dir=None) -> bool: + """读取个人空间开关 external_session_header(默认关闭)。 + + 无工作区上下文的调用方(如审核智能体)使用全局 DATA_DIR; + 有工作区上下文的调用方可传入 workspace.data_dir。 + 任何异常均按关闭处理,不影响请求主链路。 + """ + try: + from modules.personalization_manager import load_personalization_config + + if base_dir is None: + from config import DATA_DIR + + base_dir = DATA_DIR + config = load_personalization_config(base_dir) + return bool(config.get("external_session_header")) + except Exception: + return False + + +def get_or_create_conversation_session_id(conversation_id: str, manager) -> Optional[str]: + """读取对话 metadata 中的 external_session_id;缺失则生成并写回(惰性创建)。 + + Args: + conversation_id: 对话 ID + manager: ConversationManager 实例(需具备 load_conversation / + update_conversation_metadata 能力) + + Returns: + session ID 字符串;读取或写入失败时返回 None(不影响请求主链路)。 + """ + if not conversation_id or manager is None: + return None + try: + data = manager.load_conversation(conversation_id) or {} + metadata = data.get("metadata") or {} + except Exception: + return None + existing = metadata.get(SESSION_METADATA_KEY) + if isinstance(existing, str) and existing.strip(): + return existing.strip() + new_id = new_external_session_id() + try: + manager.update_conversation_metadata(conversation_id, {SESSION_METADATA_KEY: new_id}) + except Exception: + return None + return new_id + + +def resolve_conversation_headers( + base_url: Optional[str], + conversation_id: Optional[str], + manager=None, + base_dir=None, +) -> Dict[str, str]: + """主对话/标题生成用:开关 + 域名判定后返回该对话的稳定 session 头。 + + 不满足条件时返回空 dict。 + """ + if not conversation_id: + return {} + if not external_session_header_enabled(base_dir): + return {} + if not is_opencode_endpoint(base_url): + return {} + sid = get_or_create_conversation_session_id(conversation_id, manager) + return {"x-opencode-session": sid} if sid else {} + + +def resolve_ephemeral_headers(base_url: Optional[str], base_dir=None) -> Dict[str, str]: + """无对话连续性的调用(审核智能体等)用:每次生成一次性 session 头。 + + 不满足条件时返回空 dict。 + """ + if not external_session_header_enabled(base_dir): + return {} + if not is_opencode_endpoint(base_url): + return {} + return {"x-opencode-session": new_external_session_id()} diff --git a/modules/goal_review_agent.py b/modules/goal_review_agent.py index 32424732..e11a70b9 100644 --- a/modules/goal_review_agent.py +++ b/modules/goal_review_agent.py @@ -23,6 +23,7 @@ from typing import Any, Callable, Dict, List, Optional import httpx from config import PROMPTS_DIR, LOGS_DIR +from modules.external_session import build_user_agent, resolve_ephemeral_headers from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY from modules.review_agent_config import resolve_review_agent_config from modules.i18n import tr @@ -188,7 +189,9 @@ class GoalReviewAgent: _flush_trace(out) return out endpoint = f"{url.rstrip('/')}/chat/completions" - headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + # 以「产品名/版本号」自标识;审核调用无对话连续性,每次审核生成一次性 session ID + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": build_user_agent()} + headers.update(resolve_ephemeral_headers(url)) timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)) extra_params = dict(self.cfg.get("extra_params") or {}) diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 20bf8641..f8aba060 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -83,6 +83,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "default_work_mode": "plan", # 默认运行模式:plan / ask / execute "auto_generate_title": True, "title_model": "", # 对话标题生成使用的子智能体模型条目名(空=跟随主对话默认模型) + "external_session_header": False, # 向 opencode.ai 端点发送 x-opencode-session 头(默认关闭,opt-in) "recent_conversations_prompt_enabled": False, "recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, "project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数:None-无上限 / >=5-该值 @@ -280,6 +281,9 @@ def sanitize_personalization_payload( ) base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"])) base["title_model"] = str(data.get("title_model", base.get("title_model", "")) or "").strip() + base["external_session_header"] = bool( + data.get("external_session_header", base.get("external_session_header", False)) + ) base["recent_conversations_prompt_enabled"] = bool( data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False)) ) diff --git a/modules/sub_agent/task.py b/modules/sub_agent/task.py index d7a82874..5b6fb581 100644 --- a/modules/sub_agent/task.py +++ b/modules/sub_agent/task.py @@ -119,6 +119,9 @@ class SubAgentTask: self.max_turns = _max_turns_int if _max_turns_int > 0 else None self.current_context_tokens: int = 0 self._compress_round: int = 0 + # 外部会话标识(x-opencode-session):从已有对话文件恢复(重启/恢复场景), + # 无则首次请求时惰性生成;深度压缩后重置为新 ID。 + self._external_session_id: Optional[str] = self._load_external_session_id() self.messages: List[Dict[str, Any]] = [ {"role": "system", "content": system_prompt}, @@ -764,6 +767,8 @@ class SubAgentTask: client.model_key = chosen_key client.project_path = str(self.manager.project_path) client.apply_profile(model_map[chosen_key]) + # 外部会话标识(x-opencode-session):随子对话生命周期稳定,压缩后重置 + client.extra_headers_resolver = self._resolve_external_session_headers return client, chosen_key async def _call_model( @@ -1044,6 +1049,43 @@ class SubAgentTask: # 兜底用内存中的 return self.system_prompt + def _load_external_session_id(self) -> Optional[str]: + """从子对话文件恢复外部会话标识(重启/恢复场景);不存在返回 None。""" + try: + if self.conversation_file.exists(): + data = json.loads(self.conversation_file.read_text(encoding="utf-8")) + value = data.get("external_session_id") + if isinstance(value, str) and value.strip(): + return value.strip() + except Exception: + pass + return None + + def _resolve_external_session_headers(self, base_url: Optional[str]) -> Dict[str, str]: + """APIClient extra_headers_resolver:为本子智能体对话解析 x-opencode-session 头。 + + 个人空间开关开启且端点为 opencode.ai 时返回稳定 session ID;首次请求 + 惰性生成,随 _persist_conversation 落盘,深度压缩后重置。 + 任何异常返回空 dict,不影响主请求。 + """ + try: + from modules.external_session import ( + external_session_header_enabled, + is_opencode_endpoint, + new_external_session_id, + ) + + base_dir = getattr(self.manager, "data_dir", None) + if not external_session_header_enabled(base_dir): + return {} + if not is_opencode_endpoint(base_url): + return {} + if not self._external_session_id: + self._external_session_id = new_external_session_id() + return {"x-opencode-session": self._external_session_id} + except Exception: + return {} + def _deep_compress_messages(self) -> bool: """深度压缩:把旧消息总结成一条 system 消息,重建 system prompt。 @@ -1174,6 +1216,15 @@ class SubAgentTask: self.current_context_tokens = 0 self.stats["current_context_tokens"] = 0 + # 压缩重写上下文后旧 session 的缓存亲和已失效,重置外部会话标识 + # (与主对话深压缩后重置 external_session_id 的语义一致) + try: + from modules.external_session import new_external_session_id + + self._external_session_id = new_external_session_id() + except Exception: + self._external_session_id = None + logger.info( f"[SubAgentTask] task={self.task_id} 深度压缩完成: " f"压缩 {len(old_messages)} 条消息,保留 {len(recent_messages)} 条," @@ -1253,6 +1304,7 @@ class SubAgentTask: "summary": partial_summary, "messages": self.messages, "stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)}, + "external_session_id": self._external_session_id, } self.conversation_file.parent.mkdir(parents=True, exist_ok=True) conversation_json = json.dumps(conversation_data, ensure_ascii=False) @@ -1280,6 +1332,9 @@ class SubAgentTask: if self.conversation_file.exists(): data = json.loads(self.conversation_file.read_text(encoding="utf-8")) self.messages = data.get("messages", []) + restored_session = data.get("external_session_id") + if isinstance(restored_session, str) and restored_session.strip(): + self._external_session_id = restored_session.strip() ma_debug( "sub_agent_soft_stop_recovered", task_id=self.task_id, @@ -1372,6 +1427,7 @@ class SubAgentTask: "summary": summary, "messages": self.messages, "stats": output_data["stats"], + "external_session_id": self._external_session_id, } stats_data = {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)} diff --git a/modules/workflow_review_agent.py b/modules/workflow_review_agent.py index c3aff7a4..3c0ed6b1 100644 --- a/modules/workflow_review_agent.py +++ b/modules/workflow_review_agent.py @@ -25,6 +25,7 @@ from typing import Any, Callable, Dict, List, Optional import httpx from config import PROMPTS_DIR, LOGS_DIR +from modules.external_session import build_user_agent, resolve_ephemeral_headers from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY from modules.review_agent_config import resolve_review_agent_config from modules.i18n import tr @@ -191,7 +192,9 @@ class WorkflowReviewAgent: _flush_trace(out) return out endpoint = f"{url.rstrip('/')}/chat/completions" - headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + # 以「产品名/版本号」自标识;审核调用无对话连续性,每次审核生成一次性 session ID + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": build_user_agent()} + headers.update(resolve_ephemeral_headers(url)) timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)) extra_params = dict(self.cfg.get("extra_params") or {}) diff --git a/server/app_legacy.py b/server/app_legacy.py index f7265e1c..c688f009 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -481,12 +481,27 @@ def _title_debug_log(message: str, **extra: Any) -> None: pass -async def _generate_title_async(user_message: str) -> Optional[str]: +async def _generate_title_async(user_message: str, conversation_id: Optional[str] = None, web_terminal=None) -> Optional[str]: """使用快速模型生成对话标题。""" if not user_message: _title_debug_log("skip_empty_user_message") return None client = APIClient(thinking_mode=False, web_mode=True) + # 标题生成是主对话链路的附属调用:复用主对话的外部会话标识(x-opencode-session) + if conversation_id and web_terminal is not None: + def _title_extra_headers_resolver(base_url, _cid=conversation_id, _term=web_terminal): + try: + from modules.external_session import resolve_conversation_headers + + cm = getattr(_term, "context_manager", None) + manager = cm._get_conversation_manager_for_id(_cid) if cm else None + return resolve_conversation_headers( + base_url, _cid, manager=manager, base_dir=getattr(_term, "data_dir", None) + ) + except Exception: + return {} + + client.extra_headers_resolver = _title_extra_headers_resolver try: default_model = get_default_model_key() client.model_key = default_model @@ -531,7 +546,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati return async def _runner(): - title = await _generate_title_async(user_message) + title = await _generate_title_async(user_message, conversation_id=conversation_id, web_terminal=web_terminal) if not title: _title_debug_log("title_not_generated", conversation_id=conversation_id, username=username) return diff --git a/server/chat_flow_helpers.py b/server/chat_flow_helpers.py index 87be0cf2..8f86e4af 100644 --- a/server/chat_flow_helpers.py +++ b/server/chat_flow_helpers.py @@ -35,6 +35,8 @@ async def _generate_title_async( title_prompt_path, debug_logger, model_profile: Optional[Dict[str, Any]] = None, + conversation_id: Optional[str] = None, + web_terminal=None, ) -> Optional[str]: """使用子智能体模型生成对话标题。 @@ -48,6 +50,21 @@ async def _generate_title_async( return None client = APIClient(thinking_mode=False, web_mode=True) + # 标题生成是主对话链路的附属调用:复用主对话的外部会话标识(x-opencode-session) + if conversation_id and web_terminal is not None: + def _title_extra_headers_resolver(base_url, _cid=conversation_id, _term=web_terminal): + try: + from modules.external_session import resolve_conversation_headers + + cm = getattr(_term, "context_manager", None) + manager = cm._get_conversation_manager_for_id(_cid) if cm else None + return resolve_conversation_headers( + base_url, _cid, manager=manager, base_dir=getattr(_term, "data_dir", None) + ) + except Exception: + return {} + + client.extra_headers_resolver = _title_extra_headers_resolver if not model_profile: _title_debug_log("title_model_profile_missing") return None @@ -125,7 +142,14 @@ def generate_conversation_title_background( if model_profile is None: _title_debug_log("title_model_profile_unavailable", title_model=title_model, conversation_id=conversation_id) return - title = await _generate_title_async(user_message, title_prompt_path, debug_logger, model_profile=model_profile) + title = await _generate_title_async( + user_message, + title_prompt_path, + debug_logger, + model_profile=model_profile, + conversation_id=conversation_id, + web_terminal=web_terminal, + ) if not title: _title_debug_log("title_not_generated", conversation_id=conversation_id, username=username) return diff --git a/server/deep_compression.py b/server/deep_compression.py index f3574247..9e8210b7 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -8,6 +8,7 @@ from functools import wraps from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from modules.external_session import new_external_session_id from modules.i18n import tr def _load_summary_prompt(web_terminal) -> str: @@ -650,6 +651,9 @@ async def run_deep_compression( "compression_error": summary_fail_reason, "compression_resume_payload": None, "is_ultra_long_conversation": False, + # 压缩重写上下文后旧 session 的缓存亲和已失效,重置外部会话标识 + # (与 frozen prompt 一样随压缩周期重建;开关关闭时该值不会被发送) + "external_session_id": new_external_session_id(), } for frozen_key in REBUILD_FROZEN_KEYS: meta_updates[frozen_key] = None diff --git a/static/src/components/personalization/tabs/ModelTab.vue b/static/src/components/personalization/tabs/ModelTab.vue index 8638435a..dd44deae 100644 --- a/static/src/components/personalization/tabs/ModelTab.vue +++ b/static/src/components/personalization/tabs/ModelTab.vue @@ -1,5 +1,6 @@