From acd6723b8f4941a65068ed49266181d1d3c0f4bf Mon Sep 17 00:00:00 2001 From: zuiho <2324465096@qq.com> Date: Sat, 26 Sep 2026 11:16:11 +0800 Subject: [PATCH] feat: add background channel intelligence testing and question management --- apps/desktop/src-tauri/src/lib.rs | 14 + apps/desktop/src-tauri/src/sidecar.rs | 66 ++ apps/desktop/src/App.tsx | 9 + .../src/IntelligenceQuestionManager.tsx | 368 +++++++++++ .../src/IntelligenceWorkspace.test.tsx | 369 +++++++++++ apps/desktop/src/IntelligenceWorkspace.tsx | 584 ++++++++++++++++++ apps/desktop/src/ServiceManager.test.tsx | 36 ++ apps/desktop/src/ServiceManager.tsx | 45 +- apps/desktop/src/SettingsCenter.test.tsx | 113 ++++ apps/desktop/src/SettingsCenter.tsx | 40 +- .../desktop/src/components/ServiceListRow.tsx | 52 +- apps/desktop/src/intelligence-background.ts | 81 +++ apps/desktop/src/intelligence-bridge.ts | 98 +++ apps/desktop/src/intelligence-preference.ts | 26 + apps/desktop/src/lib/question-image.test.ts | 23 + apps/desktop/src/lib/question-image.ts | 153 +++++ contracts/control-api.openapi.yaml | 461 ++++++++++++++ core/contract/intelligence.go | 57 ++ core/internal/controlapi/handler.go | 8 + core/internal/controlapi/intelligence.go | 144 +++++ core/internal/controlapi/intelligence_test.go | 178 ++++++ core/internal/intelligence/grading.go | 53 ++ core/internal/intelligence/grading_test.go | 60 ++ core/internal/intelligence/runner.go | 390 ++++++++++++ core/internal/intelligence/runner_test.go | 169 +++++ core/internal/intelligence/types.go | 175 ++++++ core/internal/servicetest/gateway.go | 4 +- core/internal/servicetest/intelligence.go | 57 ++ .../internal/servicetest/intelligence_test.go | 116 ++++ core/internal/servicetest/response.go | 35 +- core/internal/servicetest/tester.go | 41 +- core/internal/storage/intelligence.go | 13 + core/internal/storage/migrate/defaults.go | 4 + core/internal/storage/sqlite/intelligence.go | 42 ++ 34 files changed, 4055 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/IntelligenceQuestionManager.tsx create mode 100644 apps/desktop/src/IntelligenceWorkspace.test.tsx create mode 100644 apps/desktop/src/IntelligenceWorkspace.tsx create mode 100644 apps/desktop/src/intelligence-background.ts create mode 100644 apps/desktop/src/intelligence-bridge.ts create mode 100644 apps/desktop/src/intelligence-preference.ts create mode 100644 apps/desktop/src/lib/question-image.test.ts create mode 100644 apps/desktop/src/lib/question-image.ts create mode 100644 core/contract/intelligence.go create mode 100644 core/internal/controlapi/intelligence.go create mode 100644 core/internal/controlapi/intelligence_test.go create mode 100644 core/internal/intelligence/grading.go create mode 100644 core/internal/intelligence/grading_test.go create mode 100644 core/internal/intelligence/runner.go create mode 100644 core/internal/intelligence/runner_test.go create mode 100644 core/internal/intelligence/types.go create mode 100644 core/internal/servicetest/intelligence.go create mode 100644 core/internal/servicetest/intelligence_test.go create mode 100644 core/internal/storage/intelligence.go create mode 100644 core/internal/storage/sqlite/intelligence.go diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9890604..38547c0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -879,6 +879,19 @@ async fn delete_service( manager.delete_service(&service_id, &etag).await } +#[tauri::command] +async fn intelligence( + operation: String, + service_id: Option, + run_id: Option, + input: Option, + manager: State<'_, Arc>, +) -> Result { + manager + .intelligence(&operation, service_id.as_deref(), run_id.as_deref(), input) + .await +} + #[tauri::command] async fn pricing( operation: String, @@ -1442,6 +1455,7 @@ pub fn run() { delete_service, get_service_usage, pricing, + intelligence, reset_service_usage, test_service, probe_service_models, diff --git a/apps/desktop/src-tauri/src/sidecar.rs b/apps/desktop/src-tauri/src/sidecar.rs index ab5bb7a..ed1ea86 100644 --- a/apps/desktop/src-tauri/src/sidecar.rs +++ b/apps/desktop/src-tauri/src/sidecar.rs @@ -1659,6 +1659,46 @@ impl CoreManager { Ok(()) } + pub async fn intelligence( + &self, + operation: &str, + service_id: Option<&str>, + run_id: Option<&str>, + input: Option, + ) -> Result { + let base = "/control/v1/intelligence"; + let (method, path) = match operation { + "settings" => (Method::GET, format!("{base}/settings")), + "save_settings" => (Method::PUT, format!("{base}/settings")), + "channel" | "save_channel" | "history" | "start" => { + let id = service_id.ok_or("service ID is required")?; + validate_resource_id(id)?; + let path = format!("{base}/channels/{id}"); + match operation { + "channel" => (Method::GET, path), + "save_channel" => (Method::PUT, path), + "history" => (Method::GET, format!("{path}/runs")), + _ => (Method::POST, format!("{path}/runs")), + } + } + "run" | "render" | "cancel" => { + let id = run_id.ok_or("run ID is required")?; + validate_intelligence_run_id(id)?; + let path = format!("{base}/runs/{id}"); + if operation == "run" { + (Method::GET, path) + } else { + (Method::POST, format!("{path}/{operation}")) + } + } + _ => return Err("unsupported intelligence operation".into()), + }; + let (_, body) = self + .authenticated_control(method, &path, input, None) + .await?; + serde_json::from_slice(&body).map_err(|_| "intelligence returned invalid JSON".into()) + } + pub async fn pricing( &self, operation: &str, @@ -4329,6 +4369,32 @@ fn validate_exact_object_keys( Ok(()) } +fn validate_intelligence_run_id(value: &str) -> Result<(), String> { + if value.len() == 32 && value.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(()) + } else { + Err("intelligence run ID is invalid".into()) + } +} + +#[cfg(test)] +mod intelligence_id_tests { + use super::validate_intelligence_run_id; + #[test] + fn accepts_generated_numeric_run_ids_but_rejects_path_injection() { + assert!(validate_intelligence_run_id("12c54b9cbf126f6ced65663760326f6a").is_ok()); + assert!(validate_intelligence_run_id("a2c54b9cbf126f6ced65663760326f6a").is_ok()); + for invalid in [ + "../settings", + "12c54b9cbf126f6ced65663760326f6a/render", + "", + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + assert!(validate_intelligence_run_id(invalid).is_err()); + } + } +} + fn validate_resource_id(value: &str) -> Result<(), String> { if !(3..=96).contains(&value.len()) || !value.bytes().enumerate().all(|(index, byte)| match byte { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index ba64afb..e88b0ee 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; +import { resumeIntelligenceRuns } from "./intelligence-background"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { Activity, @@ -566,6 +567,14 @@ export default function App() { [snapshot?.capabilities?.protocols], ); + const intelligenceServices = catalog.items + .map((service) => service.id) + .join(","); + useEffect(() => { + if (intelligenceServices) + void resumeIntelligenceRuns(intelligenceServices.split(",")); + }, [intelligenceServices]); + return ( void; +}) { + const [settings, setSettings] = useState(); + const [saved, setSaved] = useState(); + const dirty = JSON.stringify(settings) !== JSON.stringify(saved); + useEffect(() => { + onDirtyChange(dirty); + }, [dirty, onDirtyChange]); + const [services, setServices] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + useEffect(() => { + let alive = true; + void Promise.all([ + intelligence("settings"), + listServices(), + ]) + .then(([next, page]) => { + if (alive) { + setSettings(next); + setSaved(next); + setServices(page.items); + } + }) + .catch((error) => { + if (alive) setError(String(error)); + }); + return () => { + alive = false; + }; + }, []); + const action = async (fn: () => Promise) => { + setBusy(true); + setError(""); + setMessage(""); + try { + await fn(); + } catch (error) { + setError(String(error)); + } finally { + setBusy(false); + } + }; + return ( +
+ {error && ( +

+ {error} +

+ )} + {message && ( +

+ {message} +

+ )} + {settings ? ( + + ) : ( + !error &&

正在加载题库…

+ )} +
+ ); +} + +export function IntelligenceQuestionEditor({ + settings, + setSettings, + services, + busy, + action, + setMessage, + onSaved, +}: { + settings: IntelligenceSettings; + setSettings: (settings: IntelligenceSettings) => void; + services: Service[]; + busy: boolean; + action: (fn: () => Promise) => Promise; + setMessage: (message: string) => void; + onSaved?: (settings: IntelligenceSettings) => void; +}) { + const [selected, setSelected] = useState(settings.questions[0]?.id ?? ""); + const question = settings.questions.find((q) => q.id === selected); + const judge = services.find((s) => s.id === settings.judge_service_id); + const toggle = (ids: string[], id: string) => + ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]; + const updateQuestion = (patch: Partial) => { + if (question) + setSettings({ + ...settings, + questions: settings.questions.map((q) => + q.id === question.id ? { ...q, ...patch } : q, + ), + }); + }; + return ( +
+
+ ({ + value: q.id, + label: q.name, + }))} + /> + + +
+
+ + 默认模型与图片判定设置 + +
+
+ + + setSettings({ + ...settings, + default_model: e.target.value, + }) + } + /> + + + + setSettings({ + ...settings, + timeout_seconds: Number(e.target.value), + }) + } + /> + +
+
+ + ({ + value: s.id, + label: s.name, + })), + ]} + onChange={(id) => + setSettings({ + ...settings, + judge_service_id: id, + judge_model: "", + }) + } + /> + + + + setSettings({ ...settings, judge_model: model }) + } + /> + +
+
+
+ {question && ( +
+
+ + updateQuestion({ name: e.target.value })} + /> + + + + updateQuestion({ + kind: kind as IntelligenceQuestion["kind"], + }) + } + options={[ + { value: "text", label: "问答(文本答案)" }, + { value: "number", label: "问答(数字答案)" }, + { value: "svg", label: "SVG 画图" }, + ]} + /> + +
+ {question.kind === "svg" && ( + + { + const file = e.target.files?.[0]; + if (file) + void action(async () => + updateQuestion({ + reference_png: await readReferenceImage(file), + }), + ); + }} + /> + {question.reference_png && ( +
+ 正常参考图 + +
+ )} +
+ )} + + +