From c08e7f2c547cacbc177e8cfcd94ea66ab8860eb1 Mon Sep 17 00:00:00 2001 From: fish Date: Mon, 6 Jul 2026 14:10:32 +0800 Subject: [PATCH] =?UTF-8?q?AI=20=E9=85=8D=E7=BD=AE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=8C=89=E7=94=A8=E6=88=B7=E7=8B=AC=E7=AB=8B=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个用户 AI 配置存 data/user_data/ai_settings/{username}.json, 读配置时用户级优先,无则回退到全局 secrets.json。 Co-Authored-By: Claude --- serve/backend/app/api/settings.py | 56 +++++++++++++++++++----------- serve/backend/app/secrets_store.py | 50 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/serve/backend/app/api/settings.py b/serve/backend/app/api/settings.py index ea7ea57..7667709 100644 --- a/serve/backend/app/api/settings.py +++ b/serve/backend/app/api/settings.py @@ -48,15 +48,28 @@ class TickflowKeyIn(BaseModel): api_key: str +def _get_ai_config(username: str | None, key: str, default: str = "") -> str: + """读 AI 配置: 用户级优先, 无则全局。""" + if username: + val = secrets_store.load_ai_config(username).get(key) + if val: + return val + return secrets_store.get_ai_config(key, default) + + @router.get("") -def get_settings() -> dict: +def get_settings(request: Request) -> dict: """返回当前配置概况(Key 脱敏)。""" from app.config import settings from app.services import preferences from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command + username = getattr(request.state, "username", None) key = secrets_store.get_tickflow_key() - ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider) + ai_provider = _get_ai_config(username, "ai_provider", settings.ai_provider) + ai_api_key = secrets_store.load_ai_config(username).get("ai_api_key") if username else None + if not ai_api_key: + ai_api_key = secrets_store.get_ai_key() return { "mode": tf_client.current_mode(), "tickflow_api_key_masked": secrets_store.mask(key), @@ -68,15 +81,15 @@ def get_settings() -> dict: "extras_caps": extras_caps(), # 首次使用引导 "onboarding_completed": preferences.get_onboarding_completed(), - # AI 配置 + # AI 配置 (用户级优先) "ai_provider": ai_provider, - "ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url), - "ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()), - "has_ai_key": bool(secrets_store.get_ai_key()), + "ai_base_url": _get_ai_config(username, "ai_base_url", settings.ai_base_url), + "ai_api_key_masked": secrets_store.mask(ai_api_key), + "has_ai_key": bool(ai_api_key), "ai_configured": ai_configured(ai_provider), "ai_model": current_ai_model(), "ai_codex_command": current_codex_command(), - "ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent), + "ai_user_agent": _get_ai_config(username, "ai_user_agent", settings.ai_user_agent), } @@ -238,11 +251,14 @@ class AiSettingsIn(BaseModel): @router.post("/ai") -def save_ai_settings(req: AiSettingsIn) -> dict: - """保存 AI 配置(全部持久化到 secrets.json)""" +def save_ai_settings(req: AiSettingsIn, request: Request) -> dict: + """保存 AI 配置(按用户独立存储)。""" from app.config import settings from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command + username = getattr(request.state, "username", None) + target = username if username else "global" + updates: dict = {} if req.provider: updates["ai_provider"] = req.provider @@ -255,10 +271,10 @@ def save_ai_settings(req: AiSettingsIn) -> dict: updates["ai_api_key"] = req.api_key settings.ai_api_key = req.api_key else: - secrets_store.clear("ai_api_key") + secrets_store.clear_ai_config(target, "ai_api_key") settings.ai_api_key = "" if req.provider == "codex_cli" and not req.model: - secrets_store.clear("ai_model") + secrets_store.clear_ai_config(target, "ai_model") settings.ai_model = "" elif req.model: updates["ai_model"] = req.model @@ -270,12 +286,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict: raise HTTPException(status_code=400, detail=str(exc)) from exc updates["ai_codex_command"] = codex_command settings.ai_codex_command = codex_command - # user_agent 允许清空(回到默认浏览器 UA),故无条件持久化 + # user_agent 允许清空,故无条件持久化 updates["ai_user_agent"] = req.user_agent settings.ai_user_agent = req.user_agent if updates: - secrets_store.save(updates) + secrets_store.save_ai_config(target, updates) provider = current_ai_provider() return { @@ -288,15 +304,15 @@ def save_ai_settings(req: AiSettingsIn) -> dict: @router.delete("/ai") -def clear_ai_settings() -> dict: - """一键清空 AI 配置(provider / base_url / api_key / model)。 - - 保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。 - """ +def clear_ai_settings(request: Request) -> dict: + """一键清空当前用户的 AI 配置。""" from app.config import settings - secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command") - # 同步重置运行时内存(provider 回默认值,其余置空) + username = getattr(request.state, "username", None) + target = username if username else "global" + + secrets_store.clear_ai_config(target, "ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command") + # 同步重置运行时内存 settings.ai_provider = "openai_compat" settings.ai_base_url = "" settings.ai_api_key = "" diff --git a/serve/backend/app/secrets_store.py b/serve/backend/app/secrets_store.py index 28d2c1b..8c6cdcd 100644 --- a/serve/backend/app/secrets_store.py +++ b/serve/backend/app/secrets_store.py @@ -94,3 +94,53 @@ def mask(key: str, prefix: int = 4, suffix: int = 4) -> str: if len(key) <= prefix + suffix: return "•" * len(key) return f"{key[:prefix]}{'•' * 6}{key[-suffix:]}" + + +# ================================================================ +# 按用户 AI 配置 (data/user_data/ai_settings/{username}.json) +# ================================================================ + +def _ai_config_path(username: str) -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "ai_settings" / f"{username}.json" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def load_ai_config(username: str) -> dict: + """加载指定用户的 AI 配置。用户不存在时返回空 dict。""" + p = _ai_config_path(username) + if p.exists(): + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 + logger.warning("ai_settings/%s.json malformed: %s", username, e) + return {} + + +def save_ai_config(username: str, updates: dict) -> dict: + """合并写入指定用户的 AI 配置。返回新内容。""" + current = load_ai_config(username) + current.update({k: v for k, v in updates.items() if v is not None}) + p = _ai_config_path(username) + p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8") + try: + os.chmod(p, 0o600) + except OSError: + pass + return current + + +def clear_ai_config(username: str, *keys: str) -> dict: + """清掉指定用户的 AI 配置字段。不传 keys 则删除整个文件。""" + p = _ai_config_path(username) + if not p.exists(): + return {} + if not keys: + p.unlink() + return {} + current = load_ai_config(username) + for k in keys: + current.pop(k, None) + p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8") + return current