feat: 可配置方案引擎通用原型(Mock 检索)
需求未定稿时可先跑通追问/判定/出方案;槽位与判定树外置 YAML,后续可接 Ragflow。 Co-authored-by: haijd <haijd@163.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .settings import settings
|
||||
|
||||
|
||||
def _load_yaml(name: str) -> dict[str, Any]:
|
||||
path = settings.knowledge_dir / name
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{name} must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
class BusinessConfig:
|
||||
def __init__(self) -> None:
|
||||
self.reload()
|
||||
|
||||
def reload(self) -> None:
|
||||
self.slots_doc = _load_yaml("slots.yaml")
|
||||
self.tree_doc = _load_yaml("decision_tree.yaml")
|
||||
self.chunks_doc = _load_yaml("sample_chunks.yaml")
|
||||
|
||||
@property
|
||||
def slots(self) -> list[dict[str, Any]]:
|
||||
return list(self.slots_doc.get("slots") or [])
|
||||
|
||||
@property
|
||||
def handoff_phrases(self) -> list[str]:
|
||||
return list(self.slots_doc.get("handoff_phrases") or [])
|
||||
|
||||
@property
|
||||
def closing_prompt(self) -> str:
|
||||
return str(self.slots_doc.get("closing_prompt") or "正在生成方案…")
|
||||
|
||||
@property
|
||||
def rules(self) -> list[dict[str, Any]]:
|
||||
return sorted(self.tree_doc.get("rules") or [], key=lambda r: r.get("order", 100))
|
||||
|
||||
@property
|
||||
def handoff_confidence_below(self) -> float:
|
||||
return float(self.tree_doc.get("handoff_confidence_below", 0.55))
|
||||
|
||||
@property
|
||||
def chunks(self) -> list[dict[str, Any]]:
|
||||
return list(self.chunks_doc.get("chunks") or [])
|
||||
|
||||
|
||||
business_config = BusinessConfig()
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _match_condition(cond: dict[str, Any], slots: dict[str, str]) -> bool:
|
||||
ctype = cond.get("type")
|
||||
if ctype == "always":
|
||||
return True
|
||||
slot = str(cond.get("slot") or "")
|
||||
value = str(cond.get("value") or "")
|
||||
actual = (slots.get(slot) or "").strip()
|
||||
if ctype == "slot_equals":
|
||||
return actual == value
|
||||
if ctype == "slot_contains":
|
||||
return value.lower() in actual.lower()
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_tree(rules: list[dict[str, Any]], slots: dict[str, str]) -> dict[str, Any]:
|
||||
for rule in rules:
|
||||
conditions = rule.get("when_all") or []
|
||||
if all(_match_condition(c, slots) for c in conditions):
|
||||
then = dict(rule.get("then") or {})
|
||||
return {
|
||||
"rule_id": rule.get("id"),
|
||||
"description": rule.get("description"),
|
||||
**then,
|
||||
}
|
||||
return {
|
||||
"rule_id": "empty",
|
||||
"product_bundle": ["待专家确认"],
|
||||
"tech_line": "待判定",
|
||||
"summary": "未匹配到规则",
|
||||
"confidence": 0.0,
|
||||
"handoff": True,
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .config_loader import business_config
|
||||
from .scheme_engine import create_session, handle_message
|
||||
from .settings import settings
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1)
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"app": settings.app_name,
|
||||
"retrieval_backend": settings.retrieval_backend,
|
||||
"slots": len(business_config.slots),
|
||||
"rules": len(business_config.rules),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/config/slots")
|
||||
def get_slots() -> dict:
|
||||
return business_config.slots_doc
|
||||
|
||||
|
||||
@app.get("/api/config/decision-tree")
|
||||
def get_tree() -> dict:
|
||||
return business_config.tree_doc
|
||||
|
||||
|
||||
@app.post("/api/admin/reload-config")
|
||||
def reload_config() -> dict:
|
||||
business_config.reload()
|
||||
return {"reloaded": True, "slots": len(business_config.slots), "rules": len(business_config.rules)}
|
||||
|
||||
|
||||
@app.post("/api/sessions", response_model=SessionResponse)
|
||||
def api_create_session() -> SessionResponse:
|
||||
session = create_session()
|
||||
return SessionResponse(session_id=session.id)
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
def api_chat(body: ChatRequest) -> dict:
|
||||
session_id = body.session_id
|
||||
if not session_id:
|
||||
session_id = create_session().id
|
||||
try:
|
||||
return handle_message(session_id, body.message)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="session_not_found") from None
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config_loader import business_config
|
||||
from .settings import settings
|
||||
|
||||
|
||||
class RetrievalResult:
|
||||
def __init__(self, chunks: list[dict[str, Any]]):
|
||||
self.chunks = chunks
|
||||
|
||||
|
||||
def retrieve_for_scheme(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
|
||||
backend = (settings.retrieval_backend or "mock").lower()
|
||||
if backend == "ragflow":
|
||||
return _retrieve_ragflow(slots, bundle)
|
||||
return _retrieve_mock(slots, bundle)
|
||||
|
||||
|
||||
def _retrieve_mock(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
|
||||
chunks = business_config.chunks
|
||||
picked: list[dict[str, Any]] = []
|
||||
bundle_set = set(bundle)
|
||||
for c in chunks:
|
||||
if c.get("product") in bundle_set:
|
||||
picked.append(c)
|
||||
# tag overlap with slot values
|
||||
blob = " ".join(slots.values())
|
||||
for c in chunks:
|
||||
if c in picked:
|
||||
continue
|
||||
tags = c.get("tags") or []
|
||||
if any(str(t) in blob for t in tags):
|
||||
picked.append(c)
|
||||
if len(picked) >= 4:
|
||||
break
|
||||
if not picked:
|
||||
picked = chunks[:2]
|
||||
return RetrievalResult(picked[:4])
|
||||
|
||||
|
||||
def _retrieve_ragflow(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
|
||||
"""Minimal Ragflow retrieval hook. Falls back to mock if not configured/unavailable."""
|
||||
if not settings.ragflow_api_key:
|
||||
return _retrieve_mock(slots, bundle)
|
||||
|
||||
query = " ".join(
|
||||
[
|
||||
slots.get("tech_category", ""),
|
||||
slots.get("scenario", ""),
|
||||
slots.get("key_metric", ""),
|
||||
" ".join(bundle),
|
||||
]
|
||||
).strip() or "产品方案"
|
||||
dataset_ids = [x for x in settings.ragflow_dataset_ids.split(",") if x.strip()]
|
||||
url = settings.ragflow_base_url.rstrip("/") + "/api/v1/retrieval"
|
||||
headers = {"Authorization": f"Bearer {settings.ragflow_api_key}"}
|
||||
payload: dict[str, Any] = {"question": query, "top_k": 4}
|
||||
if dataset_ids:
|
||||
payload["dataset_ids"] = dataset_ids
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=20.0) as client:
|
||||
resp = client.post(url, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return _retrieve_mock(slots, bundle)
|
||||
|
||||
# Ragflow response shapes vary by version; normalize best-effort
|
||||
records = []
|
||||
if isinstance(data, dict):
|
||||
records = data.get("data") or data.get("chunks") or data.get("records") or []
|
||||
if isinstance(records, dict):
|
||||
records = records.get("chunks") or records.get("records") or []
|
||||
normalized = []
|
||||
for i, r in enumerate(records[:4]):
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": r.get("id") or f"rf-{i}",
|
||||
"title": r.get("document_keyword") or r.get("title") or "Ragflow片段",
|
||||
"product": r.get("document_id") or "",
|
||||
"content": r.get("content") or r.get("text") or "",
|
||||
"tags": [],
|
||||
}
|
||||
)
|
||||
if not normalized:
|
||||
return _retrieve_mock(slots, bundle)
|
||||
return RetrievalResult(normalized)
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .config_loader import business_config
|
||||
from .decision import evaluate_tree
|
||||
from .retrieval import retrieve_for_scheme
|
||||
from .session_store import (
|
||||
Session,
|
||||
looks_like_handoff,
|
||||
naive_fill_slots,
|
||||
next_slot_prompt,
|
||||
store,
|
||||
)
|
||||
|
||||
|
||||
def create_session() -> Session:
|
||||
return store.create()
|
||||
|
||||
|
||||
def handle_message(session_id: str, message: str) -> dict[str, Any]:
|
||||
session = store.get(session_id)
|
||||
if not session:
|
||||
raise KeyError("session_not_found")
|
||||
|
||||
text = (message or "").strip()
|
||||
session.messages.append({"role": "user", "content": text})
|
||||
|
||||
if looks_like_handoff(text, business_config.handoff_phrases):
|
||||
session.phase = "handoff"
|
||||
reply = "已为您标记转人工。请留下称呼与联系方式,销售同事会跟进。(原型:仅记录意图,未接 Chatwoot)"
|
||||
return _finish(session, reply, need_handoff=True)
|
||||
|
||||
# fill slots while collecting
|
||||
if session.phase in ("collecting", "scheme_ready"):
|
||||
session.slots = naive_fill_slots(text, business_config.slots, session.slots)
|
||||
|
||||
nxt = next_slot_prompt(business_config.slots, session.slots)
|
||||
if nxt and session.phase == "collecting":
|
||||
# first turn with empty slots: greet + first question
|
||||
if len(session.messages) == 1 and not any(session.slots.values()):
|
||||
# put first user utterance into first slot if they already answered something
|
||||
session.slots = naive_fill_slots(text, business_config.slots, session.slots)
|
||||
nxt = next_slot_prompt(business_config.slots, session.slots)
|
||||
if nxt:
|
||||
reply = nxt["prompt"]
|
||||
return _finish(session, reply, asking_slot=nxt["id"])
|
||||
|
||||
# all required (+ optional asked or filled) → scheme
|
||||
decision = evaluate_tree(business_config.rules, session.slots)
|
||||
confidence = float(decision.get("confidence") or 0)
|
||||
need_handoff = bool(decision.get("handoff")) or confidence < business_config.handoff_confidence_below
|
||||
bundle = list(decision.get("product_bundle") or [])
|
||||
retrieval = retrieve_for_scheme(session.slots, bundle)
|
||||
|
||||
scheme = {
|
||||
"tech_line": decision.get("tech_line"),
|
||||
"product_bundle": bundle,
|
||||
"summary": decision.get("summary"),
|
||||
"confidence": confidence,
|
||||
"rule_id": decision.get("rule_id"),
|
||||
"citations": [
|
||||
{"id": c.get("id"), "title": c.get("title"), "content": c.get("content")}
|
||||
for c in retrieval.chunks
|
||||
],
|
||||
"slots": dict(session.slots),
|
||||
}
|
||||
session.last_scheme = scheme
|
||||
session.phase = "handoff" if need_handoff else "scheme_ready"
|
||||
|
||||
lines = [
|
||||
business_config.closing_prompt,
|
||||
"",
|
||||
f"技术路线:{scheme['tech_line']}",
|
||||
f"推荐组合:{', '.join(bundle)}",
|
||||
f"说明:{scheme['summary']}",
|
||||
f"置信度:{confidence:.0%}",
|
||||
]
|
||||
if scheme["citations"]:
|
||||
lines.append("")
|
||||
lines.append("知识依据(原型检索):")
|
||||
for c in scheme["citations"][:3]:
|
||||
lines.append(f"- {c['title']}:{c['content']}")
|
||||
if need_handoff:
|
||||
lines.append("")
|
||||
lines.append("系统建议转人工确认后再对外发送方案。回复「转人工」可进入人工流程。")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("可继续补充约束,或回复「转人工」交给销售。")
|
||||
|
||||
reply = "\n".join(lines)
|
||||
return _finish(session, reply, scheme=scheme, need_handoff=need_handoff)
|
||||
|
||||
|
||||
def _finish(
|
||||
session: Session,
|
||||
reply: str,
|
||||
*,
|
||||
asking_slot: str | None = None,
|
||||
scheme: dict[str, Any] | None = None,
|
||||
need_handoff: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
session.messages.append({"role": "assistant", "content": reply})
|
||||
store.touch(session)
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"reply": reply,
|
||||
"phase": session.phase,
|
||||
"slots": session.slots,
|
||||
"asking_slot": asking_slot,
|
||||
"scheme": scheme or session.last_scheme,
|
||||
"need_handoff": need_handoff,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
id: str
|
||||
slots: dict[str, str] = field(default_factory=dict)
|
||||
messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
phase: str = "collecting" # collecting | scheme_ready | handoff
|
||||
last_scheme: dict[str, Any] | None = None
|
||||
created_at: str = field(default_factory=_now)
|
||||
updated_at: str = field(default_factory=_now)
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, Session] = {}
|
||||
|
||||
def create(self) -> Session:
|
||||
session = Session(id=str(uuid.uuid4()))
|
||||
self._sessions[session.id] = session
|
||||
return session
|
||||
|
||||
def get(self, session_id: str) -> Session | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def touch(self, session: Session) -> None:
|
||||
session.updated_at = _now()
|
||||
|
||||
|
||||
store = SessionStore()
|
||||
|
||||
|
||||
def looks_like_handoff(text: str, phrases: list[str]) -> bool:
|
||||
t = text.strip().lower()
|
||||
return any(p.lower() in t for p in phrases)
|
||||
|
||||
|
||||
def naive_fill_slots(message: str, slots_meta: list[dict[str, Any]], current: dict[str, str]) -> dict[str, str]:
|
||||
"""每轮通常只推进一个槽:先做明确关键词,再把整句写入当前优先空槽。"""
|
||||
updated = dict(current)
|
||||
text = message.strip()
|
||||
if not text:
|
||||
return updated
|
||||
|
||||
touched = False
|
||||
|
||||
def set_slot(slot_id: str, value: str) -> None:
|
||||
nonlocal touched
|
||||
if not updated.get(slot_id):
|
||||
updated[slot_id] = value
|
||||
touched = True
|
||||
|
||||
if re.search(r"A类|传感", text, re.I):
|
||||
set_slot("tech_category", "A类传感")
|
||||
elif re.search(r"B类|控制", text, re.I):
|
||||
set_slot("tech_category", "B类控制")
|
||||
elif "不确定" in text:
|
||||
set_slot("tech_category", "不确定")
|
||||
|
||||
if "产线" in text:
|
||||
set_slot("scenario", "产线")
|
||||
elif "户外" in text:
|
||||
set_slot("scenario", "户外监测")
|
||||
elif "实验室" in text:
|
||||
set_slot("scenario", "实验室")
|
||||
|
||||
# 本轮若已通过关键词写入至少一个槽,就不再把整句灌进其它空槽
|
||||
if touched:
|
||||
return updated
|
||||
|
||||
pending = sorted(
|
||||
[s for s in slots_meta if s.get("required") and not updated.get(s["id"])],
|
||||
key=lambda s: s.get("priority", 100),
|
||||
)
|
||||
if pending:
|
||||
updated[pending[0]["id"]] = text
|
||||
return updated
|
||||
|
||||
optional = sorted(
|
||||
[s for s in slots_meta if not s.get("required") and not updated.get(s["id"])],
|
||||
key=lambda s: s.get("priority", 100),
|
||||
)
|
||||
if optional:
|
||||
updated[optional[0]["id"]] = text
|
||||
return updated
|
||||
|
||||
|
||||
def next_slot_prompt(slots_meta: list[dict[str, Any]], filled: dict[str, str]) -> dict[str, str] | None:
|
||||
pending = sorted(
|
||||
[s for s in slots_meta if s.get("required") and not (filled.get(s["id"]) or "").strip()],
|
||||
key=lambda s: s.get("priority", 100),
|
||||
)
|
||||
if not pending:
|
||||
# optional slots: ask once if empty
|
||||
optional = sorted(
|
||||
[s for s in slots_meta if not s.get("required") and not (filled.get(s["id"]) or "").strip()],
|
||||
key=lambda s: s.get("priority", 100),
|
||||
)
|
||||
if optional:
|
||||
s = optional[0]
|
||||
return {"id": s["id"], "label": s["label"], "prompt": s["prompt"]}
|
||||
return None
|
||||
s = pending[0]
|
||||
return {"id": s["id"], "label": s["label"], "prompt": s["prompt"]}
|
||||
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
KNOWLEDGE_DIR = ROOT / "knowledge"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
app_name: str = "鲜境方案引擎原型"
|
||||
knowledge_dir: Path = KNOWLEDGE_DIR
|
||||
retrieval_backend: str = "mock" # mock | ragflow
|
||||
ragflow_base_url: str = "http://127.0.0.1:9380"
|
||||
ragflow_api_key: str = ""
|
||||
ragflow_dataset_ids: str = ""
|
||||
llm_mode: str = "template" # template(无 Key 可跑)| openai_compatible(预留)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,73 @@
|
||||
let sessionId = null;
|
||||
|
||||
const logEl = document.getElementById("log");
|
||||
const slotsEl = document.getElementById("slots");
|
||||
const schemeEl = document.getElementById("scheme");
|
||||
const metaEl = document.getElementById("meta");
|
||||
const form = document.getElementById("form");
|
||||
const input = document.getElementById("input");
|
||||
const resetBtn = document.getElementById("reset");
|
||||
|
||||
function addBubble(role, text) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `bubble ${role}`;
|
||||
div.textContent = text;
|
||||
logEl.appendChild(div);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (sessionId) return sessionId;
|
||||
const res = await fetch("/api/sessions", { method: "POST" });
|
||||
const data = await res.json();
|
||||
sessionId = data.session_id;
|
||||
metaEl.textContent = `session: ${sessionId.slice(0, 8)}…`;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function sendMessage(message) {
|
||||
await ensureSession();
|
||||
addBubble("user", message);
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, message }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
addBubble("sys", `错误:${res.status}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
addBubble("bot", data.reply);
|
||||
slotsEl.textContent = JSON.stringify(data.slots || {}, null, 2);
|
||||
schemeEl.textContent = data.scheme
|
||||
? JSON.stringify(data.scheme, null, 2)
|
||||
: "尚未生成";
|
||||
metaEl.textContent = `session: ${sessionId.slice(0, 8)}… · phase: ${data.phase}`;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
form.querySelector("button").disabled = true;
|
||||
try {
|
||||
await sendMessage(text);
|
||||
} finally {
|
||||
form.querySelector("button").disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
resetBtn.addEventListener("click", () => {
|
||||
sessionId = null;
|
||||
logEl.innerHTML = "";
|
||||
slotsEl.textContent = "{}";
|
||||
schemeEl.textContent = "尚未生成";
|
||||
metaEl.textContent = "";
|
||||
addBubble("bot", "新会话已开始。可以直接说场景,例如:「产线检测,偏向 B 类」。");
|
||||
input.focus();
|
||||
});
|
||||
|
||||
addBubble("bot", "你好,我是鲜境方案引擎原型。先收集几个关键信息,再给出产品组合草稿。\n试试:「我们在户外做监测,指标还不确定」。");
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>鲜境方案引擎原型</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="hero">
|
||||
<p class="brand">鲜境物联 · 方案引擎原型</p>
|
||||
<h1>先跑通「追问 → 判定 → 出方案」</h1>
|
||||
<p class="sub">槽位与判定树在 <code>knowledge/*.yaml</code>,需求变了改配置即可。当前检索为 Mock,可后再接 Ragflow。</p>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="chat panel">
|
||||
<div id="log" class="log" aria-live="polite"></div>
|
||||
<form id="form" class="composer">
|
||||
<input id="input" type="text" placeholder="描述需求,或直接回答追问…" autocomplete="off" required />
|
||||
<button type="submit">发送</button>
|
||||
</form>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="reset" class="ghost">新会话</button>
|
||||
<span id="meta" class="meta"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="side panel">
|
||||
<h2>当前槽位</h2>
|
||||
<pre id="slots">{}</pre>
|
||||
<h2>最近方案</h2>
|
||||
<pre id="scheme">尚未生成</pre>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,144 @@
|
||||
:root {
|
||||
--bg0: #0f1c17;
|
||||
--bg1: #163528;
|
||||
--panel: rgba(248, 250, 246, 0.94);
|
||||
--ink: #14231c;
|
||||
--muted: #5c7268;
|
||||
--accent: #1f7a4c;
|
||||
--accent-2: #c4f06a;
|
||||
--line: rgba(20, 35, 28, 0.12);
|
||||
--bot: #e7f2ea;
|
||||
--user: #1f7a4c;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Noto Sans SC", "DM Sans", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, #2f6b4f 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at 100% 0%, #8fd36a 0%, transparent 45%),
|
||||
linear-gradient(160deg, var(--bg0), var(--bg1));
|
||||
}
|
||||
|
||||
.page { max-width: 1100px; margin: 0 auto; padding: 32px 20px 48px; }
|
||||
.hero { color: #f4faf6; margin-bottom: 22px; }
|
||||
.brand {
|
||||
margin: 0 0 8px;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--accent-2);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(1.6rem, 3vw, 2.2rem);
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sub { margin: 0; max-width: 52rem; color: rgba(244, 250, 246, 0.82); }
|
||||
.sub code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(0,0,0,0.25);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 0.9fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.35);
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,0.22);
|
||||
}
|
||||
|
||||
.chat { display: flex; flex-direction: column; min-height: 560px; overflow: hidden; }
|
||||
.log {
|
||||
flex: 1;
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 92%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
.bubble.bot { align-self: flex-start; background: var(--bot); }
|
||||
.bubble.user { align-self: flex-end; background: var(--user); color: #fff; }
|
||||
.bubble.sys { align-self: center; background: transparent; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.composer input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
}
|
||||
.composer button, .ghost {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--line);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.side { padding: 16px 18px 20px; }
|
||||
.side h2 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.side h2:first-child { margin-top: 0; }
|
||||
.side pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #eef5f0;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
max-height: 220px;
|
||||
}
|
||||
Reference in New Issue
Block a user