Add app_settings migration, settings UI, and OpenAI-compatible httpx LLM client with mocked tests. Preserve API keys on blank form submissions, require a fresh key when base_url changes, and keep AI search settings untouched for step 3. Update docs/design LLM integration and step 3 AI search notes, including prompt contract and extra-hints planning.
This commit is contained in:
+198
@@ -10,7 +10,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db, init_db
|
||||
from app.images import process_upload
|
||||
from app.llm import test_connection
|
||||
from app.llm import LLMResult
|
||||
from app.models import Box, Item, SubItem
|
||||
from app.settings_store import LLMConfig, get_app_settings, save_app_settings
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
STATIC_DIR = Path("app/static")
|
||||
@@ -88,6 +91,46 @@ def _wants_add_next(submit_action: str | None) -> bool:
|
||||
return submit_action == "save_and_add_next"
|
||||
|
||||
|
||||
def _validate_settings_origin(request: Request) -> str | None:
|
||||
"""Check Origin/Referer for same-host browser requests.
|
||||
|
||||
Returns an error message if validation fails, or None if OK.
|
||||
Missing both headers (e.g. curl, API call) is allowed for now.
|
||||
"""
|
||||
origin = request.headers.get("origin")
|
||||
referer = request.headers.get("referer")
|
||||
|
||||
if origin:
|
||||
host = request.headers.get("host", "")
|
||||
# origin includes scheme, host only has host:port
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(origin)
|
||||
origin_host = parsed.netloc
|
||||
if origin_host != host:
|
||||
return "请求来源与当前站点不一致,操作被拒绝。"
|
||||
elif referer:
|
||||
host = request.headers.get("host", "")
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(referer)
|
||||
referer_host = parsed.netloc
|
||||
if referer_host != host:
|
||||
return "请求来源与当前站点不一致,操作被拒绝。"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_base_url_scheme(base_url: str) -> str | None:
|
||||
"""Return an error message if base_url scheme is not allowed, else None."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
return "Base URL 必须以 http:// 或 https:// 开头。"
|
||||
return None
|
||||
|
||||
|
||||
def _format_average(total: int, divisor: int) -> str:
|
||||
if divisor == 0:
|
||||
return "0.0"
|
||||
@@ -267,6 +310,161 @@ def create_app() -> FastAPI:
|
||||
context={"page_title": "箱子", "boxes": boxes, "summary": summary},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.get("/settings")
|
||||
def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||
cfg = get_app_settings(db)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="settings/form.html",
|
||||
context={
|
||||
"page_title": "设置",
|
||||
"config": cfg,
|
||||
"api_key_configured": bool(cfg.api_key),
|
||||
"test_result": None,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/settings")
|
||||
def save_settings(
|
||||
request: Request,
|
||||
enabled: str | None = Form(default=None),
|
||||
base_url: str | None = Form(default=None),
|
||||
model: str | None = Form(default=None),
|
||||
api_key: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
# Origin/Referer check for browser requests
|
||||
origin_error = _validate_settings_origin(request)
|
||||
if origin_error:
|
||||
raise HTTPException(status_code=403, detail=origin_error)
|
||||
|
||||
resolved_base_url = _clean_text(base_url) or "https://api.openai.com/v1"
|
||||
|
||||
# Validate base_url scheme
|
||||
scheme_error = _validate_base_url_scheme(resolved_base_url)
|
||||
if scheme_error:
|
||||
raise HTTPException(status_code=400, detail=scheme_error)
|
||||
|
||||
resolved_model = _clean_text(model) or ""
|
||||
|
||||
# Only base_url change counts as an endpoint change — model switches
|
||||
# under the same base_url do not require a new key.
|
||||
existing_cfg = get_app_settings(db)
|
||||
submitted_key = _clean_text(api_key)
|
||||
base_url_changed = resolved_base_url != existing_cfg.base_url
|
||||
|
||||
if base_url_changed and submitted_key is None:
|
||||
# base_url changed but no new key provided — refuse to save,
|
||||
# return to settings page with a clear error message.
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="settings/form.html",
|
||||
context={
|
||||
"page_title": "设置",
|
||||
"config": LLMConfig(
|
||||
enabled=enabled == "on",
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key=existing_cfg.api_key,
|
||||
),
|
||||
"api_key_configured": bool(existing_cfg.api_key),
|
||||
"test_result": LLMResult(
|
||||
success=False,
|
||||
message="Base URL 已变更,请重新输入 API Key 后保存。",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# submitted_key is None → keep old key; str (including "") → use new value
|
||||
resolved_api_key = submitted_key
|
||||
|
||||
save_app_settings(
|
||||
db,
|
||||
enabled=enabled == "on",
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key=resolved_api_key,
|
||||
)
|
||||
return RedirectResponse(url="/settings", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.post("/settings/test")
|
||||
def test_settings_connection(
|
||||
request: Request,
|
||||
enabled: str | None = Form(default=None),
|
||||
base_url: str | None = Form(default=None),
|
||||
model: str | None = Form(default=None),
|
||||
api_key: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Origin/Referer check for browser requests
|
||||
origin_error = _validate_settings_origin(request)
|
||||
if origin_error:
|
||||
raise HTTPException(status_code=403, detail=origin_error)
|
||||
|
||||
resolved_base_url = _clean_text(base_url) or "https://api.openai.com/v1"
|
||||
|
||||
# Validate base_url scheme
|
||||
scheme_error = _validate_base_url_scheme(resolved_base_url)
|
||||
if scheme_error:
|
||||
raise HTTPException(status_code=400, detail=scheme_error)
|
||||
|
||||
resolved_model = _clean_text(model) or ""
|
||||
|
||||
# Only reuse stored key if base_url matches saved config. Model switches
|
||||
# under the same base_url can use the same key; a base_url change cannot.
|
||||
existing_cfg = get_app_settings(db)
|
||||
submitted_key = _clean_text(api_key)
|
||||
base_url_matches = resolved_base_url == existing_cfg.base_url
|
||||
if base_url_matches and submitted_key is None:
|
||||
resolved_api_key = existing_cfg.api_key
|
||||
elif submitted_key is not None:
|
||||
resolved_api_key = submitted_key
|
||||
else:
|
||||
# base_url changed but no key provided — refuse to test
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="settings/form.html",
|
||||
context={
|
||||
"page_title": "设置",
|
||||
"config": LLMConfig(
|
||||
enabled=enabled == "on",
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key="",
|
||||
),
|
||||
"api_key_configured": bool(existing_cfg.api_key),
|
||||
"test_result": LLMResult(
|
||||
success=False,
|
||||
message="Base URL 已变更,请重新输入 API Key 后再测试。",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
test_cfg = LLMConfig(
|
||||
enabled=enabled == "on",
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key=resolved_api_key or "",
|
||||
)
|
||||
|
||||
result = test_connection(test_cfg)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="settings/form.html",
|
||||
context={
|
||||
"page_title": "设置",
|
||||
"config": test_cfg,
|
||||
"api_key_configured": bool(test_cfg.api_key),
|
||||
"test_result": result,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/boxes/new")
|
||||
def new_box_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
|
||||
Reference in New Issue
Block a user