mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
feat: integrate netx ops data access and unify prompt/workspace runtime
Migrate prompt templates into runtime workspaces, add network_ops netx toolchain (latest batch injection, raw fields, SQL query), and align docs/tests/admin/runtime wiring so ops analysis can query netx detail data through stable role-scoped paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
83f46f8aa2
commit
b0e0c05d8a
60 changed files with 1143 additions and 46 deletions
|
|
@ -31,6 +31,8 @@ from oclaw.platform.persistence.sqlite_store import (
|
|||
|
||||
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
|
||||
_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"})
|
||||
_OPS_AI_ACTIVE_KEY = "ops_ai_active_llm_profile_id"
|
||||
_OPS_AI_BINDINGS_KEY = "ops_ai_agent_profile_bindings"
|
||||
|
||||
|
||||
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
|
||||
|
|
@ -136,6 +138,16 @@ def _normalize_active(
|
|||
return active_id
|
||||
|
||||
|
||||
def _normalize_active_by_key(store: SqliteStore, *, key: str, profile_ids: list[str]) -> str:
|
||||
if not profile_ids:
|
||||
return ""
|
||||
active_id = str(store.get_setting(key) or "").strip()
|
||||
if active_id not in profile_ids:
|
||||
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID if LLM_BUILTIN_OLLAMA_PROFILE_ID in profile_ids else profile_ids[0]
|
||||
store.set_setting(key, active_id)
|
||||
return active_id
|
||||
|
||||
|
||||
def include_model_mgmt_routes(
|
||||
router: APIRouter,
|
||||
*,
|
||||
|
|
@ -179,6 +191,20 @@ def include_model_mgmt_routes(
|
|||
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
|
||||
"db_path": db_path(),
|
||||
}
|
||||
ops_ai_active_id = _normalize_active_by_key(store, key=_OPS_AI_ACTIVE_KEY, profile_ids=profile_ids)
|
||||
raw_ops_bindings = parse_agent_profile_bindings(store.get_setting(_OPS_AI_BINDINGS_KEY))
|
||||
ops_ai_bindings: dict[str, str] = {}
|
||||
changed = False
|
||||
for rid in agent_role_ids():
|
||||
pid = str(raw_ops_bindings.get(rid) or "").strip()
|
||||
if pid and pid not in profile_ids:
|
||||
pid = ""
|
||||
changed = True
|
||||
ops_ai_bindings[rid] = pid
|
||||
if changed:
|
||||
store.set_setting(_OPS_AI_BINDINGS_KEY, dump_agent_profile_bindings(ops_ai_bindings))
|
||||
out["ops_ai_active_llm_profile_id"] = ops_ai_active_id
|
||||
out["ops_ai_bindings"] = ops_ai_bindings
|
||||
return out
|
||||
|
||||
@mg.post("/active")
|
||||
|
|
@ -225,6 +251,49 @@ def include_model_mgmt_routes(
|
|||
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
|
||||
return {"ok": True, "bindings": cur}
|
||||
|
||||
@mg.post("/ops-ai/active")
|
||||
def api_models_set_ops_ai_active(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
profile_ids = [str(p["id"]) for p in profiles]
|
||||
pid = str(payload.get("profile_id") or "").strip()
|
||||
if pid not in profile_ids:
|
||||
raise HTTPException(status_code=400, detail="invalid_profile_id")
|
||||
store.set_setting(_OPS_AI_ACTIVE_KEY, pid)
|
||||
return {"ok": True, "ops_ai_active_llm_profile_id": pid}
|
||||
|
||||
@mg.post("/ops-ai/bindings")
|
||||
def api_models_set_ops_ai_bindings(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
profile_ids = set(str(p["id"]) for p in profiles)
|
||||
raw = payload.get("bindings")
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(status_code=400, detail="bindings_object_required")
|
||||
cur = parse_agent_profile_bindings(store.get_setting(_OPS_AI_BINDINGS_KEY))
|
||||
for rid in agent_role_ids():
|
||||
v = raw.get(rid)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s and s not in profile_ids:
|
||||
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
|
||||
cur[rid] = s
|
||||
store.set_setting(_OPS_AI_BINDINGS_KEY, dump_agent_profile_bindings(cur))
|
||||
return {"ok": True, "ops_ai_bindings": cur}
|
||||
|
||||
@mg.post("/profiles")
|
||||
def api_models_create_profile(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ from fastapi import HTTPException
|
|||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
||||
from oclaw.runtime.agents.factory import build_gateway_executor
|
||||
from oclaw.runtime.gateway import OclawGateway
|
||||
from oclaw.runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist
|
||||
from oclaw.runtime.operations.providers.registry import build_channel_registry
|
||||
from oclaw.runtime.operations.runtime import (
|
||||
cleanup_service_processes_by_pid,
|
||||
|
|
@ -31,7 +34,7 @@ from oclaw.runtime.orchestration.vector_store import read_vector_memory_runtime
|
|||
from oclaw.platform.config.paths import PROJECT_ROOT, db_path
|
||||
from oclaw.platform.config.passwords import load_expected_password
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.agents.specialists import discover_specialist_ids
|
||||
from oclaw.runtime.agents.specialists import discover_specialist_ids, parse_agent_profile_bindings
|
||||
from oclaw.runtime.tools.mcp.installer import (
|
||||
_safe_server_id,
|
||||
detect_local_dependencies,
|
||||
|
|
@ -381,6 +384,19 @@ def build_admin_router() -> APIRouter:
|
|||
return
|
||||
raise HTTPException(status_code=403, detail="cross_tenant_forbidden")
|
||||
|
||||
def _resolve_ops_ai_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]:
|
||||
shared = str(os.getenv("OCLAW_OPS_AI_SHARED_TOKEN") or "").strip()
|
||||
token = _extract_bearer(authorization)
|
||||
if shared and token and hmac.compare_digest(shared, token):
|
||||
return {
|
||||
"tenant_id": "",
|
||||
"user_id": "ops-ai-service",
|
||||
"username": "ops-ai-service",
|
||||
"role": "service",
|
||||
"permissions": ["admin:read"],
|
||||
}
|
||||
return _resolve_auth(store, authorization)
|
||||
|
||||
def _ordered_specialists() -> list[str]:
|
||||
base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()]
|
||||
preferred = [x for x in ("generalist", "ops", "image") if x in set(base)]
|
||||
|
|
@ -3810,6 +3826,247 @@ def build_admin_router() -> APIRouter:
|
|||
store.revoke_auth_session(session_token_hash=_sha256_hex(token))
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/admin/api/ops-ai/analyze-sync")
|
||||
def api_ops_ai_analyze_sync(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_ops_ai_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
|
||||
question = str(payload.get("question") or "").strip()
|
||||
dataset_ref = payload.get("dataset_ref") if isinstance(payload.get("dataset_ref"), dict) else {}
|
||||
context = payload.get("context") if isinstance(payload.get("context"), dict) else {}
|
||||
constraints = payload.get("constraints") if isinstance(payload.get("constraints"), dict) else {}
|
||||
analysis_request_id = str(payload.get("analysis_request_id") or "").strip()
|
||||
if not question:
|
||||
raise HTTPException(status_code=400, detail="question_required")
|
||||
|
||||
severity_summary = context.get("severity_summary") if isinstance(context.get("severity_summary"), list) else []
|
||||
top_alarm_codes = context.get("top_alarm_codes") if isinstance(context.get("top_alarm_codes"), list) else []
|
||||
top_ne = context.get("top_ne") if isinstance(context.get("top_ne"), list) else []
|
||||
findings = context.get("findings") if isinstance(context.get("findings"), list) else []
|
||||
protocol_summary = context.get("protocol_summary") if isinstance(context.get("protocol_summary"), list) else []
|
||||
lang = str(constraints.get("language") or "zh-CN")
|
||||
interaction_mode = normalize_interaction_mode(payload.get("interaction_mode") or "expert")
|
||||
selected_specialist = normalize_requested_specialist(payload.get("specialist") or "ops")
|
||||
visible_profiles = store.list_llm_profiles(visible_only=True)
|
||||
visible_profile_ids = {str(p.get("id") or "").strip() for p in visible_profiles if str(p.get("id") or "").strip()}
|
||||
ops_ai_active_profile_id = str(store.get_setting("ops_ai_active_llm_profile_id") or "").strip()
|
||||
if ops_ai_active_profile_id not in visible_profile_ids:
|
||||
ops_ai_active_profile_id = ""
|
||||
raw_ops_ai_bindings = parse_agent_profile_bindings(store.get_setting("ops_ai_agent_profile_bindings"))
|
||||
|
||||
def _ops_ai_profile_for_role(role_id: str) -> str | None:
|
||||
bound = str(raw_ops_ai_bindings.get(role_id) or "").strip()
|
||||
if bound and bound in visible_profile_ids:
|
||||
return bound
|
||||
return None
|
||||
selected_profile_id = _ops_ai_profile_for_role(selected_specialist)
|
||||
if not selected_profile_id:
|
||||
raise HTTPException(status_code=400, detail=f"ops_ai_profile_unbound:{selected_specialist}")
|
||||
|
||||
ops_prompt = (
|
||||
f"问题:{question}\n"
|
||||
f"数据范围:{json.dumps(dataset_ref, ensure_ascii=False)}\n"
|
||||
f"级别分布:{json.dumps(severity_summary, ensure_ascii=False)}\n"
|
||||
f"高频告警码:{json.dumps(top_alarm_codes, ensure_ascii=False)}\n"
|
||||
f"高频网元:{json.dumps(top_ne, ensure_ascii=False)}\n"
|
||||
f"协议/领域分布:{json.dumps(protocol_summary, ensure_ascii=False)}\n"
|
||||
f"附加发现:{json.dumps(findings, ensure_ascii=False)}\n"
|
||||
"请以网络运维专家视角给出:"
|
||||
"1) 根因假设;2) 证据与缺口;3) 处置优先级;4) 需要进一步采集的数据。"
|
||||
)
|
||||
|
||||
tenant_id = str(ctx.get("tenant_id") or "")
|
||||
user_id = str(ctx.get("user_id") or "ops-ai-service")
|
||||
username = str(ctx.get("username") or "ops-ai-service")
|
||||
role = str(ctx.get("role") or "service")
|
||||
gw_result = None
|
||||
answer = ""
|
||||
gateway_error = ""
|
||||
try:
|
||||
apply_gateway_mcp_env_to_os()
|
||||
# Service-token calls may not map to a real UI user row.
|
||||
# Use internal session to avoid ui_session_owner foreign-key constraints.
|
||||
ops_session = store.create_session(
|
||||
title=f"ops-ai-sync:{str(dataset_ref.get('batch_id') or 'adhoc')}"
|
||||
)
|
||||
manager_agent = build_gateway_executor(
|
||||
store,
|
||||
lang=lang,
|
||||
specialist="generalist",
|
||||
profile_id=selected_profile_id,
|
||||
openai_api_key=None,
|
||||
llm_mode=None,
|
||||
model=None,
|
||||
base_url=None,
|
||||
# Internal ops-ai should use the global model pool bindings
|
||||
# instead of service-user personal visibility filtering.
|
||||
viewer_user_id=None,
|
||||
viewer_username="administrator",
|
||||
viewer_tenant_id=None,
|
||||
policy_session_id=str(ops_session.id),
|
||||
path_policy_tenant_id=tenant_id or None,
|
||||
path_policy_user_id=user_id or None,
|
||||
)
|
||||
specialist_factory = lambda sid: build_gateway_executor(
|
||||
store,
|
||||
lang=lang,
|
||||
specialist=sid,
|
||||
profile_id=_ops_ai_profile_for_role(str(sid or "").strip().lower()) or selected_profile_id,
|
||||
openai_api_key=None,
|
||||
llm_mode=None,
|
||||
model=None,
|
||||
base_url=None,
|
||||
viewer_user_id=None,
|
||||
viewer_username="administrator",
|
||||
viewer_tenant_id=None,
|
||||
policy_session_id=str(ops_session.id),
|
||||
path_policy_tenant_id=tenant_id or None,
|
||||
path_policy_user_id=user_id or None,
|
||||
)
|
||||
gw = OclawGateway(store=store)
|
||||
gw_result = gw.handle_turn(
|
||||
msg=StandardMessage(
|
||||
session_id=str(ops_session.id),
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
channel="ops_ai_api",
|
||||
text=ops_prompt,
|
||||
attachments=[],
|
||||
metadata={
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"role": role,
|
||||
"channel": "ops_ai_api",
|
||||
"interaction_mode": interaction_mode,
|
||||
"selected_specialist": selected_specialist,
|
||||
"memory_mode": "default",
|
||||
"execution_mode": "normal",
|
||||
},
|
||||
),
|
||||
lang=lang,
|
||||
executor=manager_agent,
|
||||
run_id=(analysis_request_id or None),
|
||||
specialist_executor_factory=specialist_factory,
|
||||
)
|
||||
answer = str(getattr(gw_result, "reply_text", "") or "")
|
||||
except Exception as exc:
|
||||
gateway_error = str(exc)
|
||||
sev_text = ";".join(
|
||||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||||
for x in severity_summary[:5]
|
||||
if isinstance(x, dict)
|
||||
)
|
||||
code_text = ";".join(
|
||||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||||
for x in top_alarm_codes[:5]
|
||||
if isinstance(x, dict)
|
||||
)
|
||||
ne_text = ";".join(
|
||||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||||
for x in top_ne[:5]
|
||||
if isinstance(x, dict)
|
||||
)
|
||||
proto_text = ";".join(
|
||||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||||
for x in protocol_summary[:5]
|
||||
if isinstance(x, dict)
|
||||
)
|
||||
answer = (
|
||||
"网关专家暂时不可用,先给出统计结论:\n"
|
||||
f"- 级别分布:{sev_text or '无'}\n"
|
||||
f"- 高频告警码:{code_text or '无'}\n"
|
||||
f"- 高频网元:{ne_text or '无'}\n"
|
||||
f"- 协议/领域:{proto_text or '无'}\n"
|
||||
"- 建议优先处理 critical/major 的热点网元,并结合 top 告警码排查是否告警风暴。"
|
||||
)
|
||||
|
||||
try:
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=str(ctx.get("tenant_id") or ""),
|
||||
actor_user_id=str(ctx.get("user_id") or ""),
|
||||
action="ops_ai_analyze_sync",
|
||||
target_type="ops_ai",
|
||||
target_id=analysis_request_id or str(dataset_ref.get("batch_id") or "unknown"),
|
||||
status="ok",
|
||||
detail={
|
||||
"question": question[:200],
|
||||
"dataset_ref": dataset_ref,
|
||||
"risk_hint": findings[:3] if findings else [],
|
||||
"mode": interaction_mode,
|
||||
"specialist": selected_specialist,
|
||||
"gateway_error": gateway_error,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"analysis_request_id": analysis_request_id,
|
||||
"answer": answer,
|
||||
"evidence": {
|
||||
"severity_summary": severity_summary[:10],
|
||||
"top_alarm_codes": top_alarm_codes[:10],
|
||||
"top_ne": top_ne[:10],
|
||||
"protocol_summary": protocol_summary[:10],
|
||||
"findings": findings[:10],
|
||||
},
|
||||
"followups": [
|
||||
"请给出过去2小时 critical 告警的 top5 网元与同比变化。",
|
||||
"请按告警码聚类,定位是否为单一根因引发的告警风暴。",
|
||||
],
|
||||
"metadata": {
|
||||
"engine": "oclaw_ops_ai_sync_v1",
|
||||
"source": "gateway_expert" if not gateway_error else "fallback_context",
|
||||
"interaction_mode": str(getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode),
|
||||
"selected_specialist": str(getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist),
|
||||
"ops_ai_active_profile_id": ops_ai_active_profile_id,
|
||||
"ops_ai_role_profile_id": _ops_ai_profile_for_role(selected_specialist) or "",
|
||||
"gateway_error": gateway_error,
|
||||
"caller": str(ctx.get("username") or ""),
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/admin/api/ops-ai/logs")
|
||||
def api_ops_ai_logs(
|
||||
limit: int = Query(default=50),
|
||||
offset: int = Query(default=0),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_ops_ai_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
lim = max(1, min(int(limit), 200))
|
||||
off = max(0, int(offset))
|
||||
rows = store.list_admin_audit_logs(
|
||||
tenant_id=str(ctx.get("tenant_id") or ""),
|
||||
action="ops_ai_analyze_sync",
|
||||
actor_user_id=None,
|
||||
status=None,
|
||||
limit=lim,
|
||||
offset=off,
|
||||
)
|
||||
total = store.count_admin_audit_logs(
|
||||
tenant_id=str(ctx.get("tenant_id") or ""),
|
||||
action="ops_ai_analyze_sync",
|
||||
actor_user_id=None,
|
||||
status=None,
|
||||
)
|
||||
return {"ok": True, "items": rows, "total": int(total), "limit": lim, "offset": off}
|
||||
|
||||
@router.get("/admin/api/ops-ai/health")
|
||||
def api_ops_ai_health(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_ops_ai_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
return {"ok": True, "service": "oclaw", "component": "ops-ai", "status": "ok", "caller": str(ctx.get("username") or "")}
|
||||
|
||||
@router.get("/admin/api/admin-audit")
|
||||
def api_admin_audit(
|
||||
limit: int = Query(default=200),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const I18N = {
|
|||
"nav.skills": "技能",
|
||||
"nav.attachments": "附件",
|
||||
"nav.profile": "用户信息",
|
||||
"nav.netx": "打开 netx 运维工具",
|
||||
"notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问",
|
||||
"action.refresh": "刷新",
|
||||
"title.stack": "运行时",
|
||||
|
|
@ -447,6 +448,7 @@ const I18N = {
|
|||
"nav.skills": "Skills",
|
||||
"nav.attachments": "Attachments",
|
||||
"nav.profile": "User Info",
|
||||
"nav.netx": "Open netx ops tool",
|
||||
"notice.noLogin": "v2 login enabled: internal network only",
|
||||
"action.refresh": "Refresh",
|
||||
"title.stack": "Runtime",
|
||||
|
|
@ -3446,6 +3448,8 @@ async function renderModels() {
|
|||
});
|
||||
const activeSelect = el("select", { class: "input", disabled: !canPickActive });
|
||||
const bindingWrap = el("div", {});
|
||||
const opsAiSpecialistSelect = el("select", { class: "input", disabled: !canConfigureBindings });
|
||||
const opsAiProfileSelect = el("select", { class: "input", disabled: !canConfigureBindings });
|
||||
const modelsGrantsLinkRow = el("div", { class: "muted", style: "display:none" });
|
||||
const newName = el("input", { class: "input", placeholder: t("models.createNamePlaceholder") });
|
||||
const newMode = el("select", { class: "input", disabled: !canMutateProfiles }, [
|
||||
|
|
@ -3772,6 +3776,29 @@ async function renderModels() {
|
|||
});
|
||||
}
|
||||
|
||||
function paintOpsAiBindings() {
|
||||
if (!state || !state.ops_ai_bindings) return;
|
||||
const profiles = state.profiles || [];
|
||||
const profileIds = profiles.map((p) => String(p.id));
|
||||
const roleIds = (Array.isArray(state.role_ids) ? state.role_ids : []).filter((rid) => String(rid || "") !== "manager");
|
||||
|
||||
const curSpecialist = String(opsAiSpecialistSelect.value || "").trim();
|
||||
opsAiSpecialistSelect.innerHTML = "";
|
||||
roleIds.forEach((rid) => {
|
||||
opsAiSpecialistSelect.appendChild(el("option", { value: String(rid), text: t("models.role." + rid) }));
|
||||
});
|
||||
if (roleIds.includes(curSpecialist)) opsAiSpecialistSelect.value = curSpecialist;
|
||||
else if (roleIds.length) opsAiSpecialistSelect.value = roleIds[0];
|
||||
|
||||
const rid = String(opsAiSpecialistSelect.value || "").trim();
|
||||
opsAiProfileSelect.innerHTML = "";
|
||||
profiles.forEach((p) => {
|
||||
opsAiProfileSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
||||
});
|
||||
const v = String((state.ops_ai_bindings && state.ops_ai_bindings[rid]) || "").trim();
|
||||
opsAiProfileSelect.value = profileIds.includes(v) ? v : "";
|
||||
}
|
||||
|
||||
function paintEval() {
|
||||
evalMetrics.innerHTML = "";
|
||||
evalTableWrap.innerHTML = "";
|
||||
|
|
@ -3833,8 +3860,11 @@ async function renderModels() {
|
|||
const aid = String(state.active_llm_profile_id || "");
|
||||
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
|
||||
activeSelect.disabled = !canPickActive;
|
||||
opsAiSpecialistSelect.disabled = !canConfigureBindings;
|
||||
opsAiProfileSelect.disabled = !canConfigureBindings;
|
||||
|
||||
paintBindings();
|
||||
paintOpsAiBindings();
|
||||
|
||||
const selProf = profiles.find((p) => String(p.id) === aid) || profiles[0] || {};
|
||||
const pid = String(selProf.id || "");
|
||||
|
|
@ -3911,6 +3941,21 @@ async function renderModels() {
|
|||
status.textContent = String(e.message || e);
|
||||
}
|
||||
});
|
||||
opsAiSpecialistSelect.addEventListener("change", async () => {
|
||||
paintOpsAiBindings();
|
||||
});
|
||||
opsAiProfileSelect.addEventListener("change", async () => {
|
||||
if (!canConfigureBindings) return;
|
||||
try {
|
||||
const rid = String(opsAiSpecialistSelect.value || "").trim();
|
||||
const next = Object.assign({}, (state && state.ops_ai_bindings) || {});
|
||||
next[rid] = String(opsAiProfileSelect.value || "");
|
||||
await apiPost("/admin/api/models/ops-ai/bindings", { bindings: next });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
status.textContent = String(e.message || e);
|
||||
}
|
||||
});
|
||||
|
||||
const btnCreate = el("button", { class: "btn btn--primary", text: t("models.createBtn"), onclick: async () => {
|
||||
if (!canMutateProfiles) return;
|
||||
|
|
@ -4129,6 +4174,7 @@ async function renderModels() {
|
|||
sections: [
|
||||
{ id: "models-overview", label: "概览" },
|
||||
{ id: "models-bindings", label: "绑定" },
|
||||
{ id: "models-ops-ai", label: "内部API" },
|
||||
{ id: "models-api", label: "API配置" },
|
||||
{ id: "models-experts", label: "Experts" },
|
||||
{ id: "models-eval", label: "评估" },
|
||||
|
|
@ -4150,6 +4196,10 @@ async function renderModels() {
|
|||
el("div", { class: "muted", text: t("models.bindingsScopeHint") }),
|
||||
bindingWrap,
|
||||
], { id: "models-bindings" }),
|
||||
renderSectionCard("内部 API模型配置", "仅用于 v1 / 专家模式:选择专家并绑定 API 配置。", [
|
||||
el("div", { class: "row" }, [el("label", { text: "专家" }), opsAiSpecialistSelect]),
|
||||
el("div", { class: "row" }, [el("label", { text: "API配置" }), opsAiProfileSelect]),
|
||||
], { id: "models-ops-ai" }),
|
||||
renderSectionCard(t("models.sectionApi"), "", [
|
||||
builtinCap,
|
||||
readonlyProfileHint,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,17 @@
|
|||
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
|
||||
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
|
||||
</div>
|
||||
<div class="nav__group">
|
||||
<div class="nav__groupTitle">运维工具</div>
|
||||
<a
|
||||
class="nav__item"
|
||||
href="http://127.0.0.1:5173/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-i18n="nav.netx"
|
||||
>打开 netx 运维工具</a
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar__footer">
|
||||
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue