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:
+175
@@ -0,0 +1,175 @@
|
||||
"""LLM client module — all network egress is concentrated here.
|
||||
|
||||
Uses ``httpx`` (already in requirements) to call OpenAI-compatible endpoints.
|
||||
No ``openai`` SDK dependency. Sync functions are fine: FastAPI runs sync
|
||||
handlers in a threadpool.
|
||||
|
||||
Public API:
|
||||
- ``is_configured(cfg)`` — returns True when the client can make calls.
|
||||
- ``test_connection(cfg)`` — minimal request to verify credentials.
|
||||
- ``expand_query(cfg, query)`` — query-term expansion (step 3 consumer).
|
||||
- ``analyze_image(...)`` — **reserved stub, not implemented**.
|
||||
|
||||
All calls go through ``_call_chat_completion()`` so tests can mock a single
|
||||
boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.settings_store import LLMConfig
|
||||
|
||||
# Sensible defaults
|
||||
_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResult:
|
||||
"""Uniform result wrapper for LLM calls."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
data: Any = None
|
||||
|
||||
|
||||
def is_configured(cfg: LLMConfig) -> bool:
|
||||
"""Return True only when the LLM is enabled AND has required fields."""
|
||||
return bool(cfg.enabled and cfg.model and cfg.api_key)
|
||||
|
||||
|
||||
def test_connection(cfg: LLMConfig) -> LLMResult:
|
||||
"""Send a minimal chat-completion request to verify the config.
|
||||
|
||||
Uses a tiny prompt to minimise cost. Returns an ``LLMResult`` indicating
|
||||
success or failure with a human-readable message.
|
||||
"""
|
||||
if not is_configured(cfg):
|
||||
return LLMResult(
|
||||
success=False,
|
||||
message="LLM 未配置或未启用(缺少 model 或 api_key)。",
|
||||
)
|
||||
|
||||
try:
|
||||
response = _call_chat_completion(
|
||||
cfg,
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
max_tokens=1,
|
||||
)
|
||||
return LLMResult(
|
||||
success=True,
|
||||
message=f"连接成功(模型:{cfg.model})。",
|
||||
data=response,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
return LLMResult(
|
||||
success=False,
|
||||
message=f"连接失败(HTTP {status})。请检查 base_url、model 和 api_key。",
|
||||
)
|
||||
except httpx.ConnectError:
|
||||
return LLMResult(
|
||||
success=False,
|
||||
message="无法连接到服务器。请检查 base_url 是否正确。",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
return LLMResult(
|
||||
success=False,
|
||||
message="连接超时。请检查网络或 base_url 是否可达。",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — graceful degradation
|
||||
return LLMResult(
|
||||
success=False,
|
||||
message=f"未知错误:{exc}",
|
||||
)
|
||||
|
||||
|
||||
def expand_query(cfg: LLMConfig, query: str) -> list[str]:
|
||||
"""Expand a search query into multiple synonymous terms via LLM.
|
||||
|
||||
**Step 3 will consume this.** Returns a list including the original query.
|
||||
If the LLM call fails or is not configured, returns ``[query]`` as a
|
||||
fallback (graceful degradation).
|
||||
"""
|
||||
if not is_configured(cfg):
|
||||
return [query]
|
||||
|
||||
try:
|
||||
response = _call_chat_completion(
|
||||
cfg,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是一个搜索词扩展助手。用户给你一个搜索词,"
|
||||
"你返回 3-5 个同义词或相关词,每行一个。"
|
||||
"不要编号、不要解释、不要标点。"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
max_tokens=100,
|
||||
)
|
||||
choices = response.get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
expanded = [
|
||||
line.strip() for line in content.strip().splitlines() if line.strip()
|
||||
]
|
||||
if expanded:
|
||||
# Always include the original query
|
||||
return [query] + [t for t in expanded if t != query]
|
||||
return [query]
|
||||
except Exception: # noqa: BLE001 — graceful degradation
|
||||
return [query]
|
||||
|
||||
|
||||
def analyze_image(cfg: LLMConfig, image_data: bytes, prompt: str) -> LLMResult:
|
||||
"""Analyze an image via LLM vision API.
|
||||
|
||||
.. note:: **Reserved stub — not implemented.** Will be filled in a future
|
||||
round for image analysis. The signature is fixed so callers can
|
||||
depend on it.
|
||||
"""
|
||||
# TODO: Implement in future round for image analysis.
|
||||
return LLMResult(
|
||||
success=False,
|
||||
message="图片分析功能尚未实现。",
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal boundary — all network calls go through this single function
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _call_chat_completion(
|
||||
cfg: LLMConfig,
|
||||
*,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 1,
|
||||
) -> dict:
|
||||
"""Call the OpenAI-compatible ``/chat/completions`` endpoint.
|
||||
|
||||
Returns the parsed JSON response body on success (status 2xx).
|
||||
Raises ``httpx.HTTPStatusError`` on non-2xx, or other ``httpx`` exceptions
|
||||
on network failures — callers handle these for graceful degradation.
|
||||
"""
|
||||
url = cfg.base_url.rstrip("/") + "/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": cfg.model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {cfg.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
+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(
|
||||
|
||||
@@ -90,3 +90,10 @@ class SubItem(Base):
|
||||
)
|
||||
|
||||
parent_item: Mapped[Item] = relationship(back_populates="subitems")
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Settings read/write helpers for the ``app_settings`` KV table.
|
||||
|
||||
Provides a typed ``LLMConfig`` dataclass and two helpers:
|
||||
|
||||
- ``get_app_settings(db) -> LLMConfig`` — reads KV rows (or returns defaults).
|
||||
- ``save_app_settings(db, ...) -> None`` — writes KV rows; API key left blank
|
||||
means "keep the old value".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AppSetting
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""All settings consumed by the LLM client and settings UI."""
|
||||
|
||||
enabled: bool = False
|
||||
base_url: str = "https://api.openai.com/v1"
|
||||
model: str = ""
|
||||
api_key: str = ""
|
||||
ai_search_enabled: bool = False
|
||||
|
||||
|
||||
def _get_value(rows: dict[str, str], key: str, default: str) -> str:
|
||||
return rows.get(key, default)
|
||||
|
||||
|
||||
def _get_bool(rows: dict[str, str], key: str, default: bool) -> bool:
|
||||
return rows.get(key, str(default).lower()) == "true"
|
||||
|
||||
|
||||
def get_app_settings(db: Session) -> LLMConfig:
|
||||
"""Read all settings from ``app_settings`` and return an ``LLMConfig``."""
|
||||
rows: dict[str, str] = {}
|
||||
for row in db.query(AppSetting).all():
|
||||
if row.value is not None:
|
||||
rows[row.key] = row.value
|
||||
|
||||
return LLMConfig(
|
||||
enabled=_get_bool(rows, "llm_enabled", False),
|
||||
base_url=_get_value(rows, "llm_base_url", "https://api.openai.com/v1"),
|
||||
model=_get_value(rows, "llm_model", ""),
|
||||
api_key=_get_value(rows, "llm_api_key", ""),
|
||||
ai_search_enabled=_get_bool(rows, "ai_search_enabled", False),
|
||||
)
|
||||
|
||||
|
||||
def save_app_settings(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
ai_search_enabled: bool | None = None,
|
||||
) -> None:
|
||||
"""Write settings to ``app_settings``.
|
||||
|
||||
If ``api_key`` is ``None`` (form field left blank), the existing key is
|
||||
preserved. All other fields are written as-is.
|
||||
"""
|
||||
updates: dict[str, str | None] = {}
|
||||
|
||||
if enabled is not None:
|
||||
updates["llm_enabled"] = str(enabled).lower()
|
||||
if base_url is not None:
|
||||
updates["llm_base_url"] = base_url
|
||||
if model is not None:
|
||||
updates["llm_model"] = model
|
||||
if api_key is not None:
|
||||
updates["llm_api_key"] = api_key
|
||||
if ai_search_enabled is not None:
|
||||
updates["ai_search_enabled"] = str(ai_search_enabled).lower()
|
||||
|
||||
for key, value in updates.items():
|
||||
existing = db.get(AppSetting, key)
|
||||
if existing is not None:
|
||||
existing.value = value
|
||||
else:
|
||||
db.add(AppSetting(key=key, value=value))
|
||||
|
||||
db.commit()
|
||||
@@ -19,6 +19,7 @@
|
||||
<nav class="top-nav">
|
||||
<a href="/boxes">箱子</a>
|
||||
<a href="/search">搜索</a>
|
||||
<a href="/settings">设置</a>
|
||||
</nav>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/boxes">箱子</a>
|
||||
<span>/</span>
|
||||
<strong>设置</strong>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p class="muted">配置 LLM 连接参数。未配置时,整站行为不受影响。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if test_result %}
|
||||
<section class="card" style="margin-bottom: 16px; border-color: {% if test_result.success %}#2f6b1f{% else %}#b42318{% endif %};">
|
||||
<p style="margin:0; color: {% if test_result.success %}#2f6b1f{% else %}#b42318{% endif %};">
|
||||
<strong>{{ "✓ " if test_result.success else "✗ " }}{{ test_result.message }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings" class="stack form-panel">
|
||||
<label class="form-field checkbox-row">
|
||||
<input type="checkbox" name="enabled" {% if config.enabled %}checked{% endif %}>
|
||||
启用 LLM
|
||||
</label>
|
||||
<p class="checkbox-help">开启后,AI 相关功能将使用下方配置连接 LLM 服务。</p>
|
||||
|
||||
<label class="form-field">
|
||||
Base URL
|
||||
<input type="text" name="base_url" value="{{ config.base_url }}" placeholder="https://api.openai.com/v1">
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
模型名称
|
||||
<input type="text" name="model" value="{{ config.model }}" placeholder="例如 gpt-4o-mini">
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
API Key
|
||||
{% if api_key_configured %}
|
||||
<input type="password" name="api_key" value="" placeholder="已配置,留空=不修改">
|
||||
{% else %}
|
||||
<input type="password" name="api_key" value="" placeholder="输入 API Key">
|
||||
{% endif %}
|
||||
</label>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="button button-primary">保存设置</button>
|
||||
<button type="submit" class="button button-secondary" formaction="/settings/test" formmethod="post">测试连接</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user