Add AI search query expansion
This commit is contained in:
+122
-25
@@ -8,6 +8,7 @@ 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).
|
||||
Returns ``ExpansionResult`` with ``terms`` and optional ``error``.
|
||||
- ``analyze_image(...)`` — **reserved stub, not implemented**.
|
||||
|
||||
All calls go through ``_call_chat_completion()`` so tests can mock a single
|
||||
@@ -16,6 +17,8 @@ boundary.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -26,6 +29,18 @@ from app.settings_store import LLMConfig
|
||||
# Sensible defaults
|
||||
_TIMEOUT_SECONDS = 30
|
||||
|
||||
# ── Prompt for query expansion (Step 3) ──────────────────────────────────
|
||||
_EXPAND_QUERY_SYSTEM_PROMPT = (
|
||||
"你是搬家物品搜索助手。用户在搜索自己打包的箱子与物品(家居/搬家场景)。"
|
||||
"给定一个搜索词,列出用户可能用来命名同一类物品的相关词:"
|
||||
"近义词、常见别称、上位类别、具体品类。"
|
||||
"规则:用与查询相同的语言;"
|
||||
"只给与该物品紧密相关、有助于在清单里找到它的词;"
|
||||
"不要解释、不要造无关词;最多 8 个;"
|
||||
"只输出一个 JSON 字符串数组,例如 "
|
||||
'`["炒锅","平底锅","汤锅","厨具"]`。'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResult:
|
||||
@@ -36,6 +51,20 @@ class LLMResult:
|
||||
data: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpansionResult:
|
||||
"""Structured result from ``expand_query``.
|
||||
|
||||
``terms`` is always a list (may be empty).
|
||||
``error`` is ``None`` on success (including legitimate empty results);
|
||||
on failure (timeout, network error, HTTP error) it contains a
|
||||
human-friendly error message.
|
||||
"""
|
||||
|
||||
terms: list[str]
|
||||
error: str | None = 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)
|
||||
@@ -87,44 +116,109 @@ def test_connection(cfg: LLMConfig) -> LLMResult:
|
||||
)
|
||||
|
||||
|
||||
def expand_query(cfg: LLMConfig, query: str) -> list[str]:
|
||||
def expand_query(
|
||||
cfg: LLMConfig,
|
||||
query: str,
|
||||
extra_hints: str = "",
|
||||
) -> ExpansionResult:
|
||||
"""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).
|
||||
Returns an ``ExpansionResult``. On success ``terms`` contains the expanded
|
||||
terms (possibly empty) and ``error`` is ``None``. On failure (network
|
||||
error, timeout, HTTP error) ``terms`` is ``[]`` and ``error`` contains a
|
||||
human-friendly message.
|
||||
"""
|
||||
if not is_configured(cfg):
|
||||
return [query]
|
||||
return ExpansionResult(terms=[])
|
||||
|
||||
system_prompt = _EXPAND_QUERY_SYSTEM_PROMPT
|
||||
if extra_hints and extra_hints.strip():
|
||||
system_prompt += "\n" + extra_hints.strip()
|
||||
|
||||
try:
|
||||
response = _call_chat_completion(
|
||||
cfg,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是一个搜索词扩展助手。用户给你一个搜索词,"
|
||||
"你返回 3-5 个同义词或相关词,每行一个。"
|
||||
"不要编号、不要解释、不要标点。"
|
||||
),
|
||||
},
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
max_tokens=100,
|
||||
max_tokens=200,
|
||||
temperature=0,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
return ExpansionResult(
|
||||
terms=[],
|
||||
error="AI 搜索请求超时,请稍后再试。",
|
||||
)
|
||||
except httpx.ConnectError:
|
||||
return ExpansionResult(
|
||||
terms=[],
|
||||
error="无法连接到 AI 服务,请检查网络或设置。",
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
return ExpansionResult(
|
||||
terms=[],
|
||||
error="AI 服务返回错误,请检查配置。",
|
||||
)
|
||||
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]
|
||||
return ExpansionResult(
|
||||
terms=[],
|
||||
error="AI 搜索暂时不可用,请稍后再试。",
|
||||
)
|
||||
|
||||
choices = response.get("choices", [])
|
||||
if not choices:
|
||||
return ExpansionResult(terms=[])
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
return ExpansionResult(terms=_parse_json_string_array(content))
|
||||
|
||||
|
||||
# ── Constants for output contract enforcement ────────────────────────────
|
||||
_MAX_EXPANSION_TERMS = 8
|
||||
_MAX_TERM_LENGTH = 30
|
||||
|
||||
|
||||
def _parse_json_string_array(content: str) -> list[str]:
|
||||
"""Parse LLM output into a list of strings.
|
||||
|
||||
Strict contract enforcement:
|
||||
1. Strip markdown code fences;
|
||||
2. Try ``json.loads`` — only accept a JSON **array of strings**;
|
||||
3. Anything else (prose, JSON objects, bad JSON) → return ``[]``.
|
||||
|
||||
This ensures the output contract is enforced by code: no matter what
|
||||
the model returns or what ``ai_search_extra_hints`` contains, only a
|
||||
valid JSON string array is accepted.
|
||||
"""
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
# Strip markdown code fences
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
# Attempt JSON parse — strictly require a list
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return []
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
|
||||
# Validate every element is a string; reject non-string items
|
||||
terms: list[str] = []
|
||||
for item in parsed:
|
||||
if not isinstance(item, str):
|
||||
return []
|
||||
cleaned = item.strip()
|
||||
if cleaned and len(cleaned) <= _MAX_TERM_LENGTH:
|
||||
terms.append(cleaned)
|
||||
|
||||
# Cap total count
|
||||
return terms[:_MAX_EXPANSION_TERMS]
|
||||
|
||||
|
||||
def analyze_image(cfg: LLMConfig, image_data: bytes, prompt: str) -> LLMResult:
|
||||
@@ -151,6 +245,7 @@ def _call_chat_completion(
|
||||
*,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 1,
|
||||
temperature: float | None = None,
|
||||
) -> dict:
|
||||
"""Call the OpenAI-compatible ``/chat/completions`` endpoint.
|
||||
|
||||
@@ -164,6 +259,8 @@ def _call_chat_completion(
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if temperature is not None:
|
||||
payload["temperature"] = temperature
|
||||
headers = {
|
||||
"Authorization": f"Bearer {cfg.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
|
||||
+134
-68
@@ -5,12 +5,12 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy import func, false, or_
|
||||
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 expand_query, is_configured, 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
|
||||
@@ -160,100 +160,130 @@ def _build_boxes_overview_summary(db: Session) -> dict[str, int | str]:
|
||||
}
|
||||
|
||||
|
||||
def _build_search_results(db: Session, query: str) -> list[dict]:
|
||||
keyword = f"%{query.lower()}%"
|
||||
def _build_search_results(db: Session, query: str | list[str]) -> list[dict]:
|
||||
"""Search Box / Item / SubItem by name and note using case-insensitive LIKE.
|
||||
|
||||
Accepts either a single query string or a list of keywords.
|
||||
When multiple keywords are given, they are combined with OR — a match on
|
||||
*any* keyword is sufficient.
|
||||
"""
|
||||
keywords = [query] if isinstance(query, str) else query
|
||||
patterns = [f"%{kw.lower()}%" for kw in keywords]
|
||||
|
||||
def _or_like(column, note_column):
|
||||
"""Build an OR filter that matches any pattern on either column."""
|
||||
conditions = []
|
||||
for pat in patterns:
|
||||
conditions.append(func.lower(column).like(pat))
|
||||
conditions.append(func.lower(func.coalesce(note_column, "")).like(pat))
|
||||
return or_(false(), *conditions) if conditions else false()
|
||||
|
||||
results: list[dict] = []
|
||||
seen_ids: set[tuple[str, int]] = set()
|
||||
|
||||
def _add(result_type: str, obj_id: int, entry: dict) -> None:
|
||||
key = (result_type, obj_id)
|
||||
if key not in seen_ids:
|
||||
seen_ids.add(key)
|
||||
results.append(entry)
|
||||
|
||||
box_matches = (
|
||||
db.query(Box)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(Box.name).like(keyword),
|
||||
func.lower(func.coalesce(Box.note, "")).like(keyword),
|
||||
)
|
||||
)
|
||||
.filter(_or_like(Box.name, Box.note))
|
||||
.order_by(Box.id.desc())
|
||||
.all()
|
||||
)
|
||||
for box in box_matches:
|
||||
results.append(
|
||||
{
|
||||
"type": "Box",
|
||||
"name": box.name,
|
||||
"note": box.note,
|
||||
"detail_url": f"/boxes/{box.id}",
|
||||
"detail_label": "查看箱子",
|
||||
"secondary_url": None,
|
||||
"secondary_label": None,
|
||||
"path": "顶层箱子",
|
||||
"is_container": None,
|
||||
"image_url": f"/boxes/{box.id}/image" if box.image_blob else None,
|
||||
}
|
||||
)
|
||||
_add("Box", box.id, {
|
||||
"type": "Box",
|
||||
"name": box.name,
|
||||
"note": box.note,
|
||||
"detail_url": f"/boxes/{box.id}",
|
||||
"detail_label": "查看箱子",
|
||||
"secondary_url": None,
|
||||
"secondary_label": None,
|
||||
"path": "顶层箱子",
|
||||
"is_container": None,
|
||||
"image_url": f"/boxes/{box.id}/image" if box.image_blob else None,
|
||||
})
|
||||
|
||||
item_matches = (
|
||||
db.query(Item)
|
||||
.join(Item.box)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(Item.name).like(keyword),
|
||||
func.lower(func.coalesce(Item.note, "")).like(keyword),
|
||||
)
|
||||
)
|
||||
.filter(_or_like(Item.name, Item.note))
|
||||
.order_by(Item.id.desc())
|
||||
.all()
|
||||
)
|
||||
for item in item_matches:
|
||||
results.append(
|
||||
{
|
||||
"type": "Item",
|
||||
"name": item.name,
|
||||
"note": item.note,
|
||||
"detail_url": f"/items/{item.id}",
|
||||
"detail_label": "查看物品",
|
||||
"secondary_url": f"/boxes/{item.box.id}",
|
||||
"secondary_label": "查看所属箱子",
|
||||
"path": f"位于箱子:{item.box.name}",
|
||||
"is_container": item.is_container,
|
||||
"image_url": f"/items/{item.id}/image" if item.image_blob else None,
|
||||
}
|
||||
)
|
||||
_add("Item", item.id, {
|
||||
"type": "Item",
|
||||
"name": item.name,
|
||||
"note": item.note,
|
||||
"detail_url": f"/items/{item.id}",
|
||||
"detail_label": "查看物品",
|
||||
"secondary_url": f"/boxes/{item.box.id}",
|
||||
"secondary_label": "查看所属箱子",
|
||||
"path": f"位于箱子:{item.box.name}",
|
||||
"is_container": item.is_container,
|
||||
"image_url": f"/items/{item.id}/image" if item.image_blob else None,
|
||||
})
|
||||
|
||||
subitem_matches = (
|
||||
db.query(SubItem)
|
||||
.join(SubItem.parent_item)
|
||||
.join(Item.box)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(SubItem.name).like(keyword),
|
||||
func.lower(func.coalesce(SubItem.note, "")).like(keyword),
|
||||
)
|
||||
)
|
||||
.filter(_or_like(SubItem.name, SubItem.note))
|
||||
.order_by(SubItem.id.desc())
|
||||
.all()
|
||||
)
|
||||
for subitem in subitem_matches:
|
||||
results.append(
|
||||
{
|
||||
"type": "SubItem",
|
||||
"name": subitem.name,
|
||||
"note": subitem.note,
|
||||
"detail_url": f"/items/{subitem.parent_item.id}",
|
||||
"detail_label": "查看所属物品",
|
||||
"secondary_url": f"/boxes/{subitem.parent_item.box.id}",
|
||||
"secondary_label": "查看所属箱子",
|
||||
"path": (
|
||||
f"位于物品:{subitem.parent_item.name} / "
|
||||
f"箱子:{subitem.parent_item.box.name}"
|
||||
),
|
||||
"is_container": None,
|
||||
"image_url": f"/subitems/{subitem.id}/image" if subitem.image_blob else None,
|
||||
}
|
||||
)
|
||||
_add("SubItem", subitem.id, {
|
||||
"type": "SubItem",
|
||||
"name": subitem.name,
|
||||
"note": subitem.note,
|
||||
"detail_url": f"/items/{subitem.parent_item.id}",
|
||||
"detail_label": "查看所属物品",
|
||||
"secondary_url": f"/boxes/{subitem.parent_item.box.id}",
|
||||
"secondary_label": "查看所属箱子",
|
||||
"path": (
|
||||
f"位于物品:{subitem.parent_item.name} / "
|
||||
f"箱子:{subitem.parent_item.box.name}"
|
||||
),
|
||||
"is_container": None,
|
||||
"image_url": f"/subitems/{subitem.id}/image" if subitem.image_blob else None,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _ai_search(db: Session, cfg: "LLMConfig", query: str) -> tuple[list[str], list[dict], str | None]:
|
||||
"""Swappable AI search seam.
|
||||
|
||||
Returns ``(expanded_terms, results, error_message)``.
|
||||
- On success: expanded terms + broadened results, ``error_message`` is ``None``.
|
||||
- On failure (timeout, network error, HTTP error): empty terms + normal LIKE
|
||||
results + friendly error message.
|
||||
- On empty expansion (model returned ``[]`` legitimately): empty terms + normal
|
||||
results, ``error_message`` is ``None``.
|
||||
"""
|
||||
result = expand_query(cfg, query, extra_hints=cfg.ai_search_extra_hints)
|
||||
|
||||
if result.error:
|
||||
# Real failure (timeout / network / HTTP) → show error + fallback
|
||||
results = _build_search_results(db, query)
|
||||
return [], results, result.error
|
||||
|
||||
if not result.terms:
|
||||
# Legitimate empty expansion → normal results, no error
|
||||
results = _build_search_results(db, query)
|
||||
return [], results, None
|
||||
|
||||
# Deduplicate: original query + expanded terms
|
||||
all_terms = list(dict.fromkeys([query] + result.terms))
|
||||
results = _build_search_results(db, all_terms)
|
||||
return result.terms, results, None
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -285,10 +315,28 @@ def create_app() -> FastAPI:
|
||||
def search_page(
|
||||
request: Request,
|
||||
q: str | None = None,
|
||||
ai: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = (q or "").strip()
|
||||
results = _build_search_results(db, query) if query else []
|
||||
cfg = get_app_settings(db)
|
||||
ai_requested = ai == "1"
|
||||
ai_available = cfg.ai_search_enabled and is_configured(cfg)
|
||||
expanded_terms: list[str] = []
|
||||
ai_error: str | None = None
|
||||
|
||||
if query:
|
||||
if ai_requested and ai_available:
|
||||
try:
|
||||
expanded_terms, results, ai_error = _ai_search(db, cfg, query)
|
||||
except Exception: # noqa: BLE001 — graceful degradation
|
||||
ai_error = "AI 搜索暂时不可用,已回退到普通搜索。"
|
||||
results = _build_search_results(db, query)
|
||||
else:
|
||||
results = _build_search_results(db, query)
|
||||
else:
|
||||
results = []
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="search/index.html",
|
||||
@@ -297,6 +345,10 @@ def create_app() -> FastAPI:
|
||||
"query": query,
|
||||
"results": results,
|
||||
"searched": bool(query),
|
||||
"ai_activated": ai_requested and ai_available and bool(query),
|
||||
"expanded_terms": expanded_terms,
|
||||
"ai_error": ai_error,
|
||||
"ai_available": ai_available,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -335,6 +387,8 @@ def create_app() -> FastAPI:
|
||||
base_url: str | None = Form(default=None),
|
||||
model: str | None = Form(default=None),
|
||||
api_key: str | None = Form(default=None),
|
||||
ai_search_enabled: str | None = Form(default=None),
|
||||
ai_search_extra_hints: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
# Origin/Referer check for browser requests
|
||||
@@ -370,6 +424,8 @@ def create_app() -> FastAPI:
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key=existing_cfg.api_key,
|
||||
ai_search_enabled=ai_search_enabled == "on",
|
||||
ai_search_extra_hints=_clean_text(ai_search_extra_hints) or "",
|
||||
),
|
||||
"api_key_configured": bool(existing_cfg.api_key),
|
||||
"test_result": LLMResult(
|
||||
@@ -382,12 +438,16 @@ def create_app() -> FastAPI:
|
||||
# submitted_key is None → keep old key; str (including "") → use new value
|
||||
resolved_api_key = submitted_key
|
||||
|
||||
resolved_extra_hints = _clean_text(ai_search_extra_hints) or ""
|
||||
|
||||
save_app_settings(
|
||||
db,
|
||||
enabled=enabled == "on",
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key=resolved_api_key,
|
||||
ai_search_enabled=ai_search_enabled == "on",
|
||||
ai_search_extra_hints=resolved_extra_hints,
|
||||
)
|
||||
return RedirectResponse(url="/settings", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@@ -398,6 +458,8 @@ def create_app() -> FastAPI:
|
||||
base_url: str | None = Form(default=None),
|
||||
model: str | None = Form(default=None),
|
||||
api_key: str | None = Form(default=None),
|
||||
ai_search_enabled: str | None = Form(default=None),
|
||||
ai_search_extra_hints: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Origin/Referer check for browser requests
|
||||
@@ -436,6 +498,8 @@ def create_app() -> FastAPI:
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key="",
|
||||
ai_search_enabled=ai_search_enabled == "on",
|
||||
ai_search_extra_hints=_clean_text(ai_search_extra_hints) or "",
|
||||
),
|
||||
"api_key_configured": bool(existing_cfg.api_key),
|
||||
"test_result": LLMResult(
|
||||
@@ -450,6 +514,8 @@ def create_app() -> FastAPI:
|
||||
base_url=resolved_base_url,
|
||||
model=resolved_model,
|
||||
api_key=resolved_api_key or "",
|
||||
ai_search_enabled=ai_search_enabled == "on",
|
||||
ai_search_extra_hints=_clean_text(ai_search_extra_hints) or "",
|
||||
)
|
||||
|
||||
result = test_connection(test_cfg)
|
||||
|
||||
@@ -25,6 +25,7 @@ class LLMConfig:
|
||||
model: str = ""
|
||||
api_key: str = ""
|
||||
ai_search_enabled: bool = False
|
||||
ai_search_extra_hints: str = ""
|
||||
|
||||
|
||||
def _get_value(rows: dict[str, str], key: str, default: str) -> str:
|
||||
@@ -48,6 +49,7 @@ def get_app_settings(db: Session) -> LLMConfig:
|
||||
model=_get_value(rows, "llm_model", ""),
|
||||
api_key=_get_value(rows, "llm_api_key", ""),
|
||||
ai_search_enabled=_get_bool(rows, "ai_search_enabled", False),
|
||||
ai_search_extra_hints=_get_value(rows, "ai_search_extra_hints", ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +61,7 @@ def save_app_settings(
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
ai_search_enabled: bool | None = None,
|
||||
ai_search_extra_hints: str | None = None,
|
||||
) -> None:
|
||||
"""Write settings to ``app_settings``.
|
||||
|
||||
@@ -77,6 +80,8 @@ def save_app_settings(
|
||||
updates["llm_api_key"] = api_key
|
||||
if ai_search_enabled is not None:
|
||||
updates["ai_search_enabled"] = str(ai_search_enabled).lower()
|
||||
if ai_search_extra_hints is not None:
|
||||
updates["ai_search_extra_hints"] = ai_search_extra_hints
|
||||
|
||||
for key, value in updates.items():
|
||||
existing = db.get(AppSetting, key)
|
||||
|
||||
@@ -20,7 +20,31 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if query and ai_available %}
|
||||
<section class="card" style="margin-top: 8px;">
|
||||
{% if ai_activated %}
|
||||
<span class="muted">AI 搜索已启用</span>
|
||||
{% else %}
|
||||
<a href="/search?q={{ query | urlencode }}&ai=1" class="button button-secondary" style="display:inline-block; text-decoration:none;">
|
||||
AI 智能搜索
|
||||
</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if searched %}
|
||||
{% if ai_error %}
|
||||
<section class="card" style="margin-top: 8px; border-color: #b42318;">
|
||||
<p style="margin:0; color: #b42318;"><strong>{{ ai_error }}</strong></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if ai_activated and expanded_terms %}
|
||||
<section class="card" style="margin-top: 8px; border-color: #0b57d0;">
|
||||
<p style="margin:0; color: #0b57d0;"><strong>AI 帮你扩展了:</strong>{{ expanded_terms | join('、') }}</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if results %}
|
||||
<section class="stack">
|
||||
<p class="muted">共找到 {{ results|length }} 条结果。</p>
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
{% endif %}
|
||||
</label>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">
|
||||
|
||||
<label class="form-field checkbox-row">
|
||||
<input type="checkbox" name="ai_search_enabled" {% if config.ai_search_enabled %}checked{% endif %}>
|
||||
启用 AI 智能搜索
|
||||
</label>
|
||||
<p class="checkbox-help">开启后,搜索页将显示「AI 智能搜索」按钮,通过查询词扩展增强搜索结果。</p>
|
||||
|
||||
<label class="form-field">
|
||||
额外领域提示(可选)
|
||||
<textarea name="ai_search_extra_hints" rows="3" placeholder="例如:用户物品主要涉及厨房用品和电子产品">{% if config.ai_search_extra_hints %}{{ config.ai_search_extra_hints }}{% endif %}</textarea>
|
||||
</label>
|
||||
<p class="checkbox-help">追加到 AI 搜索提示词末尾,帮助模型理解你的物品领域。留空则使用默认提示词。</p>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user