feat(v2): 模型设置(用户可调小模型/大模型/管线,/config + UI)

This commit is contained in:
tzt
2026-08-30 21:58:41 +08:00
parent 5ba3cc778b
commit 8d36eeec59
7 changed files with 314 additions and 12 deletions
+1
View File
@@ -12,6 +12,7 @@ htmlcov/
.env
*.env
api_keys*.json
config/settings.json
# Models / data / runtime
models/
+58 -3
View File
@@ -39,6 +39,7 @@ _router: Optional[Router] = None
_pipeline: Optional["CollaborativePipeline"] = None
_v2stats = V2Stats() if _V2_OK else None
_review = None
_settings = None
def get_router() -> Router:
@@ -56,8 +57,23 @@ def get_review() -> "ReviewQueue":
return _review
def settings_store():
"""用户可调整设置(懒加载单例)。"""
global _settings
if _settings is None:
from gateway.settings import load_settings
_settings = load_settings()
return _settings
def rebuild_pipeline() -> None:
"""清除管线单例,下次调用重建(配置改动后生效)。"""
global _pipeline
_pipeline = None
def build_v2_pipeline(worker_cfg_override: Optional[dict] = None):
"""从配置构建 v2 协作管线(architect + worker + pipeline)。
"""从配置 + 用户设置构建 v2 协作管线(architect + worker + pipeline)。
worker_cfg_override 可注入(测试/演示用 mock)。无 API key 时 /chat 会走
本地降级路径(不崩溃)。
@@ -65,13 +81,27 @@ def build_v2_pipeline(worker_cfg_override: Optional[dict] = None):
global _pipeline
if _pipeline is None:
cfg = load_config()
s = settings_store().to_dict() if _V2_OK else {}
kb = KnowledgeBase()
architect = build_architect(cfg.get("architect", {}))
# architect(合并用户设置)
acfg = dict(cfg.get("architect", {}))
acfg.update(s.get("architect", {}))
architect = build_architect(acfg)
# worker(合并用户设置;backend 可 mock/openai/llama_server
wcfg = dict(cfg.get("worker", {}))
wcfg.update(s.get("worker", {}))
if worker_cfg_override:
wcfg.update(worker_cfg_override)
worker = build_worker(wcfg, kb=kb)
_pipeline = build_pipeline(cfg, architect, worker)
# pipeline(合并用户设置)
cfg2 = dict(cfg)
pcfg = dict(cfg.get("pipeline", {}))
pcfg.update(s.get("pipeline", {}))
cfg2["pipeline"] = pcfg
_pipeline = build_pipeline(cfg2, architect, worker)
return _pipeline
@@ -221,6 +251,31 @@ try:
raise HTTPException(status_code=404, detail=f"审核记录不存在或已审核: {review_id}")
return {"ok": True, "review_id": review_id, "verdict": verdict}
# ---------------- 模型设置(用户可调整) ----------------
@app.get("/config", tags=["settings"])
async def get_config():
"""读取当前可调整设置(小模型 / 大模型 / 管线)。"""
return settings_store().to_dict()
@app.put("/config", tags=["settings"])
async def put_config(patch: dict):
"""部分更新设置并重建管线。示例:
{"worker": {"backend": "openai", "base_url": "http://127.0.0.1:11434/v1", "temperature": 0.4}}
"""
try:
merged = settings_store().update(patch)
except Exception as e:
raise HTTPException(status_code=400, detail=f"设置非法: {e}")
rebuild_pipeline()
return merged
@app.post("/config/reset", tags=["settings"])
async def reset_config():
"""恢复默认设置并重建管线。"""
merged = settings_store().reset()
rebuild_pipeline()
return merged
# ---------------- metrics ----------------
@app.get("/metrics", tags=["system"])
async def metrics():
+114
View File
@@ -0,0 +1,114 @@
"""可调整的运行设置(SettingsStore)—— 让用户自定义内置小模型 / 大模型 / 管线。
用户可在 Web 界面"模型设置"里调整并持久化到 config/settings.jsongitignore),
重启后保留。调整会触发 v2 管线重建(gateway.api.build_v2_pipeline 重新读取)。
默认值与 config/config.yaml 的 v2 段一致;settings.json 只存用户改动覆盖项。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Optional
_SETTINGS_PATH = Path(__file__).resolve().parent.parent / "config" / "settings.json"
# 可调整项(含默认值);用户改动存这里
DEFAULTS: Dict[str, Any] = {
"worker": {
"backend": "llama_server", # mock | openai/api | llama_server
"model": "models/qwen3.5-4b-q4_k_m.gguf",
"base_url": "", # openai 后端填 http://127.0.0.1:11434/v1 等
"port": 8901, # llama_server 端口
"temperature": 0.3,
"max_fix_attempts": 2,
"code_timeout_s": 10,
},
"architect": {
"model": "deepseek-chat",
"base_url": "https://api.deepseek.com/v1",
"api_key_env": "DEEPSEEK_API_KEY",
},
"pipeline": {
"fast_path": True,
"rounds_cap": 6,
"api_token_cap": 8000,
"breach_policy": "architect_do",
},
}
class SettingsStore:
"""用户可调整设置(内存 + settings.json 持久化)。"""
def __init__(self, path: Optional[Path] = None):
self._path = Path(path) if path else _SETTINGS_PATH
self._data: Dict[str, Any] = {}
self.load()
# ---------- 持久化 ----------
def load(self) -> None:
if self._path.exists():
try:
self._data = json.loads(self._path.read_text(encoding="utf-8"))
except Exception:
self._data = {}
else:
self._data = {}
def save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(
json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------- 访问 ----------
def to_dict(self) -> Dict[str, Any]:
"""返回 默认值 + 用户覆盖 合并后的完整设置。"""
merged: Dict[str, Any] = {}
for section, defaults in DEFAULTS.items():
ov = self._data.get(section, {})
merged[section] = {**defaults, **(ov if isinstance(ov, dict) else {})}
return merged
def get(self, section: str, key: str, default: Any = None) -> Any:
merged = self.to_dict()
return merged.get(section, {}).get(key, default)
def update(self, patch: Dict[str, Any]) -> Dict[str, Any]:
"""应用部分更新(可只传改动的 section/key)。返回合并后的完整设置。"""
for section, values in patch.items():
if section not in DEFAULTS or not isinstance(values, dict):
continue
cur = self._data.setdefault(section, {})
for k, v in values.items():
if k in DEFAULTS[section]:
cur[k] = _coerce(v, DEFAULTS[section][k])
self.save()
return self.to_dict()
def reset(self) -> Dict[str, Any]:
"""恢复默认。"""
self._data = {}
self.save()
return self.to_dict()
def _coerce(value: Any, template: Any) -> Any:
"""按默认值的类型把输入转成一致类型(数值容错)。"""
if isinstance(template, bool):
return bool(value)
if isinstance(template, int):
try:
return int(float(value))
except (TypeError, ValueError):
return template
if isinstance(template, float):
try:
return float(value)
except (TypeError, ValueError):
return template
return value
def load_settings(path: Optional[Path] = None) -> SettingsStore:
return SettingsStore(path)
+58 -1
View File
@@ -58,6 +58,9 @@ input,select{background:var(--panel2);border:1px solid var(--line);color:var(--t
.muted{color:var(--muted)}
.history{max-height:220px;overflow:auto;margin-top:8px}
table{width:100%;border-collapse:collapse;font-size:13px}
.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.form-grid label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--muted)}
.form-grid input,.form-grid select{width:100%}
th,td{text-align:left;padding:8px;border-bottom:1px solid var(--line)}
th{color:var(--muted);font-weight:600}
footer{padding:8px 20px;color:var(--muted);font-size:11px;border-top:1px solid var(--line);background:var(--panel)}
@@ -74,6 +77,7 @@ footer{padding:8px 20px;color:var(--muted);font-size:11px;border-top:1px solid v
<button data-tab="work">🔗 协作过程</button>
<button data-tab="review">🧑‍💻 人工检验</button>
<button data-tab="metrics">📊 指标</button>
<button data-tab="settings">⚙️ 模型设置</button>
</nav>
<main>
<!-- 对话 -->
@@ -115,6 +119,53 @@ footer{padding:8px 20px;color:var(--muted);font-size:11px;border-top:1px solid v
<div class="row-btns" style="margin-bottom:10px"><button class="small" id="refreshMetrics">刷新</button></div>
<div id="metricsResult"></div>
</section>
<!-- 模型设置 -->
<section class="panel" id="panel-settings">
<div class="card">
<h3>⚙️ 内置小模型(Worker</h3>
<div class="form-grid">
<label>后端 backend
<select id="set-worker-backend">
<option value="llama_server">llama_server(内置本地模型)</option>
<option value="openai">openai/apiOllama / vLLM 等兼容端点)</option>
<option value="mock">mock(演示,零运行时)</option>
</select>
</label>
<label>模型路径/名称 <input id="set-worker-model" type="text" placeholder="models/xxx.gguf 或模型名"></label>
<label>端点 base_urlopenai 用) <input id="set-worker-baseurl" type="text" placeholder="http://127.0.0.1:11434/v1"></label>
<label>端口(llama_server <input id="set-worker-port" type="number"></label>
<label>temperature <input id="set-worker-temperature" type="number" step="0.1"></label>
<label>max_fix_attempts <input id="set-worker-fix" type="number"></label>
</div>
</div>
<div class="card">
<h3>🧠 大模型(Architect / API</h3>
<div class="form-grid">
<label>model <input id="set-arch-model" type="text"></label>
<label>base_url <input id="set-arch-baseurl" type="text"></label>
<label>api_key_env <input id="set-arch-keyenv" type="text"></label>
</div>
</div>
<div class="card">
<h3>🔀 管线 pipeline</h3>
<div class="form-grid">
<label>fast_path(快路径直答) <input id="set-pipe-fast" type="checkbox"></label>
<label>rounds_cap <input id="set-pipe-rounds" type="number"></label>
<label>api_token_cap <input id="set-pipe-tokens" type="number"></label>
<label>breach_policy
<select id="set-pipe-breach">
<option value="architect_do">architect_do(兜底代做)</option>
<option value="local_only">local_only(本地降级)</option>
</select>
</label>
</div>
</div>
<div class="row-btns">
<button class="primary" id="saveSettingsBtn">保存并生效</button>
<button class="small" id="resetSettingsBtn">恢复默认</button>
<span class="muted" id="settingsMsg"></span>
</div>
</section>
</main>
<footer>端云协同 LLM 协作系统 · v2 · 交流文本协议 · 北极星:token 下降 ≥80%</footer>
<script>
@@ -123,7 +174,7 @@ let lastRequestId=null;
const esc=s=>{const d=document.createElement("div");d.textContent=s==null?"":String(s);return d.innerHTML;};
function el(tag,cls,txt){const n=document.createElement(tag);if(cls)n.className=cls;if(txt!=null)n.textContent=txt;return n;}
function setTab(t){document.querySelectorAll("nav button").forEach(b=>b.classList.toggle("active",b.dataset.tab===t));document.querySelectorAll(".panel").forEach(p=>p.classList.toggle("active",p.id==="panel-"+t));}
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{setTab(b.dataset.tab);if(b.dataset.tab==="review")loadReview();if(b.dataset.tab==="metrics")loadMetrics();});
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{setTab(b.dataset.tab);if(b.dataset.tab==="review")loadReview();if(b.dataset.tab==="metrics")loadMetrics();if(b.dataset.tab==="settings")loadSettings();});
// ---- 健康状态 ----
async function health(){try{const r=await fetch("/health");const d=await r.json();$("healthPill").textContent="健康 · "+d.domains.length+" 领域";}catch(e){$("healthPill").textContent="离线";}}
@@ -154,6 +205,12 @@ async function sendLegacy(){const q=$("query").value.trim();if(!q)return;const h
const SAMPLES=["用 Python 实现快速排序,并分析时间与空间复杂度","求解方程 x^2 - 5x + 6 = 0","高血压患者日常饮食需要注意什么","解释一下深度学习中的注意力机制"];
function sample(){$("query").value=SAMPLES[Math.floor(Math.random()*SAMPLES.length)];}
// ---- 模型设置 ----
async function loadSettings(){try{const r=await fetch("/config");const s=await r.json();const w=s.worker||{};$("set-worker-backend").value=w.backend||"llama_server";$("set-worker-model").value=w.model||"";$("set-worker-baseurl").value=w.base_url||"";$("set-worker-port").value=w.port||8901;$("set-worker-temperature").value=w.temperature||0.3;$("set-worker-fix").value=w.max_fix_attempts||2;const a=s.architect||{};$("set-arch-model").value=a.model||"";$("set-arch-baseurl").value=a.base_url||"";$("set-arch-keyenv").value=a.api_key_env||"";const p=s.pipeline||{};$("set-pipe-fast").checked=!!p.fast_path;$("set-pipe-rounds").value=p.rounds_cap||6;$("set-pipe-tokens").value=p.api_token_cap||8000;$("set-pipe-breach").value=p.breach_policy||"architect_do";$("settingsMsg").textContent="";}catch(e){$("settingsMsg").textContent="读取失败:"+e.message;}}
async function saveSettings(){const patch={worker:{backend:$("set-worker-backend").value,model:$("set-worker-model").value,base_url:$("set-worker-baseurl").value,port:parseInt($("set-worker-port").value||8901),temperature:parseFloat($("set-worker-temperature").value||0.3),max_fix_attempts:parseInt($("set-worker-fix").value||2)},architect:{model:$("set-arch-model").value,base_url:$("set-arch-baseurl").value,api_key_env:$("set-arch-keyenv").value},pipeline:{fast_path:$("set-pipe-fast").checked,rounds_cap:parseInt($("set-pipe-rounds").value||6),api_token_cap:parseInt($("set-pipe-tokens").value||8000),breach_policy:$("set-pipe-breach").value}};try{const r=await fetch("/config",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(patch)});const d=await r.json();$("settingsMsg").textContent=r.ok?"✅ 已保存并重建管线":"❌ "+(d.detail||"保存失败");}catch(e){$("settingsMsg").textContent="保存失败:"+e.message;}}
async function resetSettings(){try{await fetch("/config/reset",{method:"POST"});$("settingsMsg").textContent="已恢复默认设置";loadSettings();}catch(e){$("settingsMsg").textContent="重置失败:"+e.message;}}
$("saveSettingsBtn").onclick=saveSettings;
$("resetSettingsBtn").onclick=resetSettings;
$("sendBtn").onclick=sendChat;
$("legacyBtn").onclick=sendLegacy;
$("sampleBtn").onclick=sample;
+15 -8
View File
@@ -155,11 +155,15 @@ class WorkerLoop:
def build_worker(cfg: Dict[str, Any], kb: Any = None,
generate: Optional[Callable[[str], Awaitable[str]]] = None) -> WorkerLoop:
"""cfg 为 config.worker 段。generate 缺省时按 backend 选择:
mock(零运行时演示)| llama_server(真实本地模型,惰性连接)。"""
mock(零运行时演示)| openai/api(任意 OpenAI 兼容端点,如 Ollama/vLLM|
llama_server(内置本地 llama-server)。"""
backend = cfg.get("backend", "llama_server")
if generate is None:
if backend == "mock":
generate = _mock_generate()
elif backend in ("openai", "api"):
generate = _make_llama_generate(
cfg, default_base_url=cfg.get("base_url") or "http://127.0.0.1:11434/v1")
else:
generate = _make_llama_generate(cfg)
verifier = Verifier(code_timeout_s=float(cfg.get("code_timeout_s", 10)))
@@ -182,10 +186,13 @@ def _mock_generate() -> Callable[[str], Awaitable[str]]:
return _gen
def _make_llama_generate(cfg: Dict[str, Any]) -> Callable[[str], Awaitable[str]]:
"""返回调用本地 llama-serverOpenAI 兼容 /v1/chat/completions)的生成器。"""
base_url = cfg.get("base_url", f"http://127.0.0.1:{cfg.get('port', 8901)}/v1")
model = cfg.get("model", "local")
def _make_llama_generate(cfg: Dict[str, Any],
default_base_url: Optional[str] = None) -> Callable[[str], Awaitable[str]]:
"""返回调用本地 OpenAI 兼容端点(llama-server / Ollama / vLLM)的生成器。"""
if default_base_url is None:
default_base_url = f"http://127.0.0.1:{cfg.get('port', 8901)}/v1"
base_url = cfg.get("base_url") or default_base_url
model = cfg.get("model") or "local"
temperature = float(cfg.get("temperature", 0.3))
timeout_s = float(cfg.get("per_step_timeout_s", 300))
@@ -201,8 +208,8 @@ def _make_llama_generate(cfg: Dict[str, Any]) -> Callable[[str], Awaitable[str]]
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except Exception as e: # noqa: BLE001
# 连不上本地模型 -> 优雅降级(不抛 500),提示用户准备运行时
return ("(本地降级)无法连接本地 llama-server,未能生成该步骤内容。"
f"先运行 scripts/setup_runtime.py 并启动模型。错误:{type(e).__name__}")
# 连不上本地模型 -> 优雅降级(不抛 500),提示用户检查模型端点
return ("(本地降级)无法连接本地模型端点,未能生成该步骤内容。"
f"检查模型后端配置或启动服务。错误:{type(e).__name__}")
return _gen
+18
View File
@@ -66,6 +66,24 @@ def test_metrics(client):
assert "review" in data
def test_config_get_put_reset(client):
# GET 默认
r = client.get("/config")
assert r.status_code == 200
assert r.json()["worker"]["backend"] in ("llama_server", "openai", "mock")
# PUT 更新 worker
r2 = client.put("/config", json={"worker": {"backend": "mock", "temperature": 0.5}})
assert r2.status_code == 200
assert r2.json()["worker"]["temperature"] == 0.5
# 重置
r3 = client.post("/config/reset")
assert r3.status_code == 200
assert r3.json()["worker"]["backend"] == "llama_server"
def test_workspace_not_found(client):
resp = client.get("/runs/nonexistent/workspace")
assert resp.status_code == 404
+50
View File
@@ -0,0 +1,50 @@
"""SettingsStore 单测(封闭:临时路径)。"""
import json
from gateway.settings import DEFAULTS, SettingsStore, _coerce
def test_defaults(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
d = s.to_dict()
assert d["worker"]["backend"] == "llama_server"
assert "pipeline" in d and "architect" in d
def test_update_and_coerce(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
s.update({"worker": {"backend": "openai", "temperature": "0.4", "max_fix_attempts": "3"}})
d = s.to_dict()
assert d["worker"]["backend"] == "openai"
assert d["worker"]["temperature"] == 0.4 # 字符串转 float
assert d["worker"]["max_fix_attempts"] == 3 # 字符串转 int
def test_ignores_unknown_keys(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
s.update({"worker": {"not_a_key": 1}, "unknown_section": {"x": 1}})
d = s.to_dict()
assert "not_a_key" not in d["worker"]
assert "unknown_section" not in d
def test_persistence_roundtrip(tmp_path):
p = tmp_path / "s.json"
s = SettingsStore(path=p)
s.update({"worker": {"temperature": 0.5}})
s2 = SettingsStore(path=p) # 重新加载
assert s2.to_dict()["worker"]["temperature"] == 0.5
def test_reset(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
s.update({"worker": {"backend": "mock"}})
d = s.reset()
assert d["worker"]["backend"] == "llama_server"
def test_coerce():
assert _coerce("true", True) is True
assert _coerce("42", 0) == 42
assert _coerce("bad", 0) == 0
assert _coerce(1.5, 0.0) == 1.5