AI 配置改为按用户独立存储

每个用户 AI 配置存 data/user_data/ai_settings/{username}.json,
读配置时用户级优先,无则回退到全局 secrets.json。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:10:32 +08:00
parent 9ecfc2bb71
commit c08e7f2c54
2 changed files with 86 additions and 20 deletions
+36 -20
View File
@@ -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 = ""
+50
View File
@@ -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