ymq 9ec38df6b4 feat(gateway): 会话模型选择持久化到项目——设置后持续生效直到再次选择
问题:项目选择模型后不持久化,每次进入会话都回退固定缺省值。
根因:前端模型下拉的 model_id 后端不消费;无项目级模型存储;
     会话只读用户全局 default_llm_id,sd_projects.default_model 无写入路径。

修复(通用层,所有产线共用):
- resolve_project 读出 sd_projects.default_model(项目级、跨会话持久)
- run_message 新增 model_id 参数:_resolve_and_persist_model 校验
  (兼容 llm.id/llm.model_id 两种取值 + 多租户隔离)后持久化到项目
- 模型优先级:本次显式选择 > 项目已设 > 个人全局 > 产线默认
  (个人全局从压过一切降为兜底,项目设置不再被全局覆盖)
2026-08-28 12:26:18 +08:00

265 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
pipeline-service: gateway — agent 网关(统一消息入口 + 会话生命周期 + 通道注册)
gateway 的核心价值:通道无关的统一消息入口。
Web(AgentIO) / 微信 等通道都通过 gateway.run_message() 触达 agent
共享同一套「解析项目上下文 → 加载产线能力 → AgentExecutor 执行」逻辑。
标准化事件流channel-neutral NDJSON所有通道可消费
{"type":"progress","message":...}
{"type":"tool_call","tool":...,"params":...}
{"type":"tool_result","result":...}
{"type":"reply","message":...}
{"type":"ask_user","message":...}
{"type":"error","message":...}
安全模型(微信通道):机构级公众号 + 用户 openid 绑定 + 匿名拒绝,
通道身份openid→ 系统 user_id 的映射由各通道适配器负责gateway 只认 user_id。
"""
import json
import logging
import time
from dataclasses import dataclass, field
from typing import AsyncGenerator, Dict, Optional
logger = logging.getLogger("pipeline.gateway")
@dataclass
class GatewaySession:
"""一个会话 = (channel, user_id, session_id) 的 agent 上下文元数据executor 每次新建)。"""
channel: str
user_id: str
session_id: str = "" # 会话内唯一标识web 多 tab 独立会话);空 = 单会话(向后兼容)
project_id: str = ""
pipeline_id: str = ""
project_name: str = ""
created_at: float = 0.0
updated_at: float = 0.0
approve_all: bool = False # 会话级"全部确认":用户评估后授权,本会话不再询问危险命令
pending_confirm: Optional[dict] = None # 待确认工具 {"tool":..., "params":...}(危险命令等待用户确认)
class Gateway:
"""agent 网关。"""
def __init__(self):
self._sessions: Dict[str, GatewaySession] = {} # key = f"{channel}:{user_id}"
self._channels: Dict[str, object] = {}
# ── 通道注册 ──
def register_channel(self, name: str, channel) -> None:
"""注册一个通道适配器(如 web/wechat"""
self._channels[name] = channel
logger.info(f"Gateway channel registered: {name}")
def list_channels(self) -> list:
return list(self._channels.keys())
# ── 会话生命周期 ──
@staticmethod
def _session_key(channel: str, user_id: str, session_id: str = "") -> str:
if session_id:
return f"{channel}:{user_id}:{session_id}"
return f"{channel}:{user_id}"
def get_session(self, channel: str, user_id: str, session_id: str = "") -> Optional[GatewaySession]:
return self._sessions.get(self._session_key(channel, user_id, session_id))
def clear_session(self, channel: str, user_id: str, session_id: str = "") -> None:
"""清除会话(/reset 时用)。"""
self._sessions.pop(self._session_key(channel, user_id, session_id), None)
# ── 上下文解析 ──
async def resolve_project(self, user_id: str, session_id: str = "") -> dict:
"""解析用户当前项目 → {pid, pipeline_id, name, default_llm_id, default_llm_name, project_model_name}。
pid 按会话隔离session_id 非空时读 pipeline_session_settings无记录回退全局
同时读出个人选择的模型default_llm_id → llm.name仍按用户全局与项目级模型
sd_projects.default_model产线通用、跨会话持久供 run_message 按优先级覆盖。
"""
ctx = {"pid": "", "pipeline_id": "", "name": "", "default_llm_id": "",
"default_llm_name": "", "project_model_name": ""}
if not user_id:
return ctx
try:
from sqlor.dbpools import DBPools
from .workspace import get_session_project_id
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
# 当前项目:按会话隔离(多 tab 各自项目,互不覆盖)
ctx["pid"] = await get_session_project_id(sor, user_id, session_id) or ""
# 个人模型选择:仍按用户全局读
recs = await sor.sqlExe(
"SELECT default_llm_id FROM pipeline_agent_settings WHERE user_id=${u}$",
{"u": user_id})
if recs:
ctx["default_llm_id"] = getattr(recs[0], "default_llm_id", "") or ""
# 单独查 llm.name避免 JOIN 触发两表字段 collation 不一致)
if ctx["default_llm_id"]:
llm_recs = await sor.sqlExe(
"SELECT name FROM llm WHERE id=${id}$ AND status='active'",
{"id": ctx["default_llm_id"]})
if llm_recs:
ctx["default_llm_name"] = getattr(llm_recs[0], "name", "") or ""
if ctx["pid"]:
proj = await sor.sqlExe(
"SELECT name, pipeline_id, default_model FROM sd_projects WHERE id=${p}$",
{"p": ctx["pid"]})
if proj:
ctx["name"] = getattr(proj[0], "name", "")
ctx["pipeline_id"] = getattr(proj[0], "pipeline_id", "") or ""
ctx["project_model_name"] = getattr(proj[0], "default_model", "") or ""
except Exception as e:
logger.warning(f"gateway resolve_project failed: {e}")
return ctx
# ── 统一消息入口 ──
async def run_message(self, channel: str, user_id: str, content: str,
history=None, role: str = "", generic: bool = False,
base_url: str = "", session_id: str = "",
pipeline_id: str = "", model_id: str = "") -> AsyncGenerator[str, None]:
"""统一消息入口:解析上下文 → 加载产线能力 → AgentExecutor 执行 → yield 事件流。
Args:
channel: 通道名web / wechat / ...
user_id: 系统用户 ID通道适配器负责把 openid 映射到 user_id
content: 用户消息内容
history: 可选历史消息
role: 可选产线内角色(驾驶舱 agent 为空)
generic: True = 纯通用会话(不解析项目、不加载产线能力,只用 GENERAL_TOOLS + 通用心智)
session_id: 会话内唯一标识web 多 tab 独立会话用;空 = 单会话)
pipeline_id: 产线默认能力(会话入口指定,如 bidding_general/sdlc_general
无当前项目时用此产线装载能力,替代硬回退 DEFAULT_ABILITY_ID。
model_id: 前端模型下拉选中的模型llm.id 或 llm.model_id。非空时
① 校验模型存在且可用 → 解析出 llm.name
② 持久化到当前项目sd_projects.default_model产线通用——
项目模型一经设置即生效,直到用户再次选择,不因新会话/换 tab 丢失;
③ 本条消息直接使用该模型。
空 = 未重新选择,沿用项目已设模型。
"""
from pipeline_core.agent_config import load_agent_config
from .agent_loop_v2 import AgentExecutor
# 1. 解析项目上下文(纯通用模式跳过,不挂任何产线插件)
if generic:
ctx = {"pid": "", "pipeline_id": "", "name": "", "project_model_name": ""}
else:
ctx = await self.resolve_project(user_id, session_id)
# 无当前项目 → 用入口指定的默认产线(投标/开发产线各自独立入口的关键)
if not ctx.get("pipeline_id") and pipeline_id:
ctx["pipeline_id"] = pipeline_id
# 1.5 模型解析与项目级持久化(优先级:本次显式选择 > 项目已设 > 个人全局 > 产线默认)
chosen_model = ""
if model_id:
chosen_model = await self._resolve_and_persist_model(
user_id, ctx.get("pid", ""), model_id)
# 2. 会话生命周期(跟踪 project 切换session_id 区分多 tab 会话)
key = self._session_key(channel, user_id, session_id)
sess = self._sessions.get(key)
now = time.time()
if sess is None or sess.project_id != ctx["pid"]:
sess = GatewaySession(
channel=channel, user_id=user_id, session_id=session_id,
project_id=ctx["pid"], pipeline_id=ctx["pipeline_id"],
project_name=ctx["name"], created_at=now, updated_at=now,
)
self._sessions[key] = sess
else:
sess.project_id = ctx["pid"]
sess.pipeline_id = ctx["pipeline_id"]
sess.project_name = ctx["name"]
sess.updated_at = now
# 3. 加载产线能力 + 创建 executorgeneric 时不装产线能力)
config = await load_agent_config(
pipeline_id=ctx["pipeline_id"], project_id=ctx["pid"], generic=generic)
# 模型覆盖优先级:本次选择 > 项目模型 > 个人全局选择(个人全局只兜底,不再压过项目设置)
if chosen_model:
config.model_name = chosen_model
elif ctx.get("project_model_name"):
config.model_name = ctx["project_model_name"]
elif ctx.get("default_llm_name"):
config.model_name = ctx["default_llm_name"]
executor = AgentExecutor(
config=config, project_id=ctx["pid"], user_id=user_id, role=role,
base_url=base_url, generic=generic, session=sess, session_id=session_id,
default_pipeline_id=pipeline_id)
# 4. 转发标准化事件流
async for chunk in executor.run(content, history=history):
yield chunk
async def _resolve_and_persist_model(self, user_id: str, project_id: str,
model_id: str) -> str:
"""校验前端选中的模型并持久化到项目。返回 llm.name空 = 无效/未持久化,调用方走回退链)。
model_id 兼容两种取值llm.id主键或 llm.model_idAPI 模型名,如 deepseek-v4-pro——
前端 UiCode 的 valueField 历史上两种都用过,这里统一解析,避免语义漂移。
多租户隔离:非系统级用户只能选本机构 + 系统级共享模型。
"""
if not model_id:
return ""
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
org_id = ""
try:
urecs = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${u}$", {"u": user_id})
if urecs:
org_id = getattr(urecs[0], "orgid", "") or ""
except Exception:
pass
recs = await sor.sqlExe(
"SELECT id, name, org_id FROM llm "
"WHERE status='active' AND (id=${m}$ OR model_id=${m}$) LIMIT 1",
{"m": model_id})
await sor.sqlExe("COMMIT", {})
if not recs:
logger.warning(f"run_message: model_id 无效(不存在或未启用): {model_id}")
return ""
rec = recs[0]
llm_org = getattr(rec, "org_id", "") or ""
if org_id and org_id != "0" and llm_org not in ("", "0", org_id):
logger.warning(f"run_message: 模型不属于本机构: {model_id} (org={org_id})")
return ""
name = getattr(rec, "name", "") or ""
# 持久化到项目:项目模型一经设置即生效,直到用户再次选择
if project_id and name:
cur = await sor.sqlExe(
"SELECT default_model FROM sd_projects WHERE id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
if cur and (getattr(cur[0], "default_model", "") or "") != name:
await sor.sqlExe(
"UPDATE sd_projects SET default_model=${m}$ WHERE id=${p}$",
{"m": name, "p": project_id})
await sor.sqlExe("COMMIT", {})
logger.info(
f"项目模型已更新: project={project_id} model={name} by={user_id}")
return name
except Exception as e:
logger.warning(f"_resolve_and_persist_model failed: {e}")
return ""
# ── 全局单例 ──
_gateway: Optional[Gateway] = None
def get_gateway() -> Gateway:
global _gateway
if _gateway is None:
_gateway = Gateway()
return _gateway