diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbf19167..7f13dc49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -503,6 +503,102 @@ jobs: path: apps/omninova-ios/release-assets/** if-no-files-found: error + cli: + name: CLI ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset_platform: linux-x64 + bin: omninova + ext: "" + - name: macOS (Intel) + os: macos-latest + target: x86_64-apple-darwin + asset_platform: macos-intel + bin: omninova + ext: "" + - name: macOS (Apple Silicon) + os: macos-14 + target: aarch64-apple-darwin + asset_platform: macos-arm64 + bin: omninova + ext: "" + - name: Windows + os: windows-latest + target: x86_64-pc-windows-msvc + asset_platform: windows-x64 + bin: omninova.exe + ext: ".exe" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup MSVC + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config build-essential libssl-dev + + - name: Prepare CLI version + id: meta + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + VERSION="${GITHUB_REF_NAME#v}" + else + BRANCH_NAME="${GITHUB_REF_NAME:-branch}" + SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's#[^a-z0-9._-]#-#g') + VERSION="${SANITIZED_BRANCH}-sha-${GITHUB_SHA::7}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build omninova CLI + shell: bash + run: cargo build --release -p omninova-core --bin omninova --target ${{ matrix.target }} + + - name: Stage CLI artifact + shell: bash + run: | + set -euo pipefail + mkdir -p cli-assets + SRC="target/${{ matrix.target }}/release/${{ matrix.bin }}" + if [ ! -f "$SRC" ]; then + echo "::error::CLI binary not found at $SRC" + ls -R target/${{ matrix.target }}/release | head -50 || true + exit 1 + fi + DEST="cli-assets/omninova-${{ steps.meta.outputs.version }}-${{ matrix.asset_platform }}${{ matrix.ext }}" + cp "$SRC" "$DEST" + ls -lh cli-assets/ + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: release-cli-${{ matrix.asset_platform }} + path: cli-assets/** + if-no-files-found: error + release: name: Publish GitHub Release if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') @@ -510,6 +606,7 @@ jobs: - desktop - android - ios + - cli runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -530,7 +627,7 @@ jobs: find . -mindepth 2 -name "SHA256SUMS.txt" -type f -delete mapfile -t release_files < <( find . -type f \ - \( -name "*.dmg" -o -name "*.deb" -o -name "*.appimage" -o -name "*.rpm" -o -name "*.exe" -o -name "*.msi" -o -name "*.tar.gz" -o -name "*.apk" -o -name "*.aab" -o -name "*.ipa" \) \ + \( -name "*.dmg" -o -name "*.deb" -o -name "*.appimage" -o -name "*.rpm" -o -name "*.exe" -o -name "*.msi" -o -name "*.tar.gz" -o -name "*.apk" -o -name "*.aab" -o -name "*.ipa" -o -name "omninova-*-linux-x64" -o -name "omninova-*-macos-intel" -o -name "omninova-*-macos-arm64" \) \ -print | sort ) if [ "${#release_files[@]}" -eq 0 ]; then @@ -560,6 +657,10 @@ jobs: release-assets/**/*.apk release-assets/**/*.aab release-assets/**/*.ipa + release-assets/**/omninova-*-linux-x64 + release-assets/**/omninova-*-macos-intel + release-assets/**/omninova-*-macos-arm64 + release-assets/**/omninova-*-windows-x64.exe release-assets/**/IOS_INSTALL.txt release-assets/**/macOS-安装与修复.txt release-assets/SHA256SUMS.txt diff --git a/Cargo.lock b/Cargo.lock index 512779d1..da3af71c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -420,6 +429,21 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calamine" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1" +dependencies = [ + "byteorder", + "codepage", + "encoding_rs", + "log", + "quick-xml 0.31.0", + "serde", + "zip", +] + [[package]] name = "camino" version = "1.2.2" @@ -582,6 +606,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -811,6 +844,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -885,6 +929,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -2467,6 +2522,7 @@ dependencies = [ "axum", "base64 0.22.1", "base64ct", + "calamine", "clap", "config", "deranged", @@ -2776,7 +2832,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2942,6 +2998,16 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -5674,12 +5740,41 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 85ee913f..e31e9762 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,3 +53,4 @@ home = "=0.5.9" avif-serialize = "=0.8.2" base64ct = "=1.6.0" qrcode = { version = "0.14", default-features = false } +calamine = "0.26" diff --git a/apps/omninova-android/gradle/wrapper/gradle-wrapper.properties b/apps/omninova-android/gradle/wrapper/gradle-wrapper.properties index 1af9e093..9ffa8c76 100644 --- a/apps/omninova-android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/omninova-android/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,8 @@ +#Fri May 22 13:18:16 CST 2026 +networkTimeout=10000 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip -networkTimeout=10000 validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/apps/omninova-tauri/package.json b/apps/omninova-tauri/package.json index d8f5410c..c720608f 100644 --- a/apps/omninova-tauri/package.json +++ b/apps/omninova-tauri/package.json @@ -1,7 +1,7 @@ { "name": "omninova-tauri", "private": true, - "version": "0.1.0", + "version": "0.2.0-alpha.2", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/omninova-tauri/scripts/build-platform.mjs b/apps/omninova-tauri/scripts/build-platform.mjs index 9fb5fa4a..08dbf730 100644 --- a/apps/omninova-tauri/scripts/build-platform.mjs +++ b/apps/omninova-tauri/scripts/build-platform.mjs @@ -1,4 +1,6 @@ import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; import process from "node:process"; import { platformCommands } from "./platforms.mjs"; @@ -8,6 +10,46 @@ const normalizedEnv = { CI: process.env.CI === "1" ? "true" : process.env.CI, }; +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const srcTauriDir = path.resolve(scriptDir, "../src-tauri"); + +/** + * 在桌面应用打包前,先把随包 CLI(omninova 二进制)以 release 构建出来, + * 使 src-tauri/build.rs 能将其复制进 resources/cli 一并打包。 + * 对带 --target 的交叉构建,传入相同 triple,保证产物路径与 build.rs 推导一致。 + */ +const buildBundledCli = (command) => { + if (command.category !== "desktop" || !command.args.includes("build")) { + return; + } + const cargoArgs = [ + "build", + "--release", + "-p", + "omninova-core", + "--bin", + "omninova", + ]; + if (command.target) { + cargoArgs.push("--target", command.target); + } + console.log(`> cargo ${cargoArgs.join(" ")}`); + const result = spawnSync("cargo", cargoArgs, { + stdio: "inherit", + cwd: srcTauriDir, + shell: process.platform === "win32", + env: normalizedEnv, + }); + if (result.error) { + console.error(`构建随包 CLI 失败:${result.error.message}`); + process.exit(1); + } + if ((result.status ?? 0) !== 0) { + console.error("构建随包 CLI 失败:cargo 退出码非 0"); + process.exit(result.status ?? 1); + } +}; + const listCommands = () => { console.log("Available OmniNova Tauri build commands:\n"); @@ -39,6 +81,8 @@ if (!command) { process.exit(1); } +buildBundledCli(command); + const tauriArgs = ["run", "tauri", "--", ...command.args, ...extraArgs]; console.log(`> npm ${tauriArgs.join(" ")}`); diff --git a/apps/omninova-tauri/src-tauri/build.rs b/apps/omninova-tauri/src-tauri/build.rs index eb063ab9..c5a93c5e 100644 --- a/apps/omninova-tauri/src-tauri/build.rs +++ b/apps/omninova-tauri/src-tauri/build.rs @@ -1,34 +1,79 @@ +use std::path::PathBuf; + fn main() { tauri_build::build(); - let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + // Windows: raise the executable's default thread stack reserve to 8 MiB. + // + // The `Config` struct is large and deeply nested, so serde (de)serialization + // to/from JSON/TOML is stack-hungry. Tauri deserializes command arguments and + // serializes results on its IPC/main threads — created with the OS default + // stack size (PE `SizeOfStackReserve`, ~1 MiB by default on MSVC). That caused + // a `0xC00000FD` (STATUS_STACK_OVERFLOW) crash when saving the model config. + // Threads created with stack size 0 inherit this reserve, so this covers the + // main thread, Tauri/wry internal threads and the IPC response path. + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os == "windows" && target_env == "msvc" { + println!("cargo:rustc-link-arg-bins=/STACK:8388608"); + } + + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); let workspace_root = manifest_dir.join("../../.."); let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".into()); + // `target` 平台三元组(交叉构建时由 cargo 通过 --target 注入)。 + let target_triple = std::env::var("TARGET").ok(); let bin_name = if cfg!(target_os = "windows") { "omninova.exe" } else { "omninova" }; - let src = workspace_root - .join("target") - .join(&profile) - .join(bin_name); + + // 候选产物目录,按优先级排列: + // 1) 由 OUT_DIR 反推的「profile 目录」(同时适配 target//); + // 2) 显式 target//; + // 3) 顶层 target/(host 构建)。 + let mut candidates: Vec = Vec::new(); + if let Ok(out_dir) = std::env::var("OUT_DIR") { + // OUT_DIR = .../target[/]//build/-/out + // 向上第 3 层即 profile 目录。 + if let Some(profile_dir) = PathBuf::from(&out_dir).ancestors().nth(3) { + candidates.push(profile_dir.join(bin_name)); + } + } + if let Some(triple) = &target_triple { + candidates.push( + workspace_root + .join("target") + .join(triple) + .join(&profile) + .join(bin_name), + ); + } + candidates.push(workspace_root.join("target").join(&profile).join(bin_name)); + let dst_dir = manifest_dir.join("resources/cli"); let dst = dst_dir.join(bin_name); - if src.exists() { - let _ = std::fs::create_dir_all(&dst_dir); - match std::fs::copy(&src, &dst) { - Ok(_) => println!( - "cargo:warning=Bundled omninova CLI: {} -> {}", - src.display(), - dst.display() - ), - Err(e) => println!("cargo:warning=Failed to copy omninova CLI: {e}"), + + let found = candidates.iter().find(|p| p.exists()); + match found { + Some(src) => { + let _ = std::fs::create_dir_all(&dst_dir); + match std::fs::copy(src, &dst) { + Ok(_) => println!( + "cargo:warning=Bundled omninova CLI: {} -> {}", + src.display(), + dst.display() + ), + Err(e) => println!("cargo:warning=Failed to copy omninova CLI: {e}"), + } + } + None => { + println!( + "cargo:warning=omninova CLI not found in any of {:?} — release builds via `npm run build:` build it automatically; for manual builds run: cargo build --release -p omninova-core --bin omninova{}", + candidates.iter().map(|p| p.display().to_string()).collect::>(), + target_triple.as_ref().map(|t| format!(" --target {t}")).unwrap_or_default() + ); } - } else { - println!( - "cargo:warning=omninova CLI not found at {} — run: cargo build -p omninova-core --bin omninova", - src.display() - ); } } diff --git a/apps/omninova-tauri/src-tauri/src/lib.rs b/apps/omninova-tauri/src-tauri/src/lib.rs index 46b38188..110f8090 100644 --- a/apps/omninova-tauri/src-tauri/src/lib.rs +++ b/apps/omninova-tauri/src-tauri/src/lib.rs @@ -10,7 +10,10 @@ use omninova_core::gateway::{ }; use omninova_core::providers::{ProviderSelection, build_provider_with_selection}; use omninova_core::routing::RouteDecision; +use omninova_core::employees::{EmployeeManifest, EmployeeSummary}; +use omninova_core::knowledge::{KnowledgeDocumentSummary, KnowledgeUploadSummary}; use omninova_core::skills::{import_skills_from_dir, load_skills_from_dir}; +use std::path::Path; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -209,6 +212,9 @@ struct SetupAppConfig { observability: SetupObservabilityConfig, #[serde(default)] audit: SetupAuditConfig, + /// 执行确认模式:不确定/高风险操作(shell、写文件、浏览器等)先在对话中征求用户同意。 + #[serde(default)] + require_confirmation: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -328,6 +334,47 @@ async fn save_config( Ok(()) } +#[tauri::command] +async fn list_knowledge_documents( + state: tauri::State<'_, Arc>>, +) -> Result, String> { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + Ok(runtime.knowledge.list_documents()) +} + +#[tauri::command] +async fn upload_knowledge_excel( + source_path: String, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime + .knowledge + .ingest_excel(Path::new(source_path.trim())) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn delete_knowledge_document( + doc_id: String, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime + .knowledge + .delete_document(doc_id.trim()) + .map_err(|e| e.to_string()) +} + #[tauri::command] async fn reload_config( state: tauri::State<'_, Arc>>, @@ -373,9 +420,7 @@ async fn save_setup_config( let mut next = setup_config_to_core(current, config)?; let next_gateway_url = format!("http://{}:{}", next.gateway.host, next.gateway.port); - if let Err(error) = save_config_with_fallback(&mut next) { - eprintln!("[config warning] {error}"); - } + save_config_with_fallback(&mut next)?; runtime.set_config(next).await.map_err(|e| e.to_string())?; if current_gateway_url != next_gateway_url { @@ -513,6 +558,119 @@ struct UiSessionHistoryQuery { channel: Option, } +#[tauri::command] +async fn delete_chat_session( + session_id: String, + channel: Option, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + let channel = channel.unwrap_or(ChannelKind::Web); + runtime + .delete_chat_session(&channel, session_id.trim()) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn list_employees( + state: tauri::State<'_, Arc>>, +) -> Result, String> { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + Ok(runtime.list_employees().await) +} + +#[tauri::command] +async fn save_employee( + manifest: EmployeeManifest, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime.save_employee(manifest).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn set_employee_enabled( + id: String, + enabled: bool, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime + .set_employee_enabled(id.trim(), enabled) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn delete_employee( + id: String, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime + .delete_employee(id.trim()) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn list_pending_approvals( + state: tauri::State<'_, Arc>>, +) -> Result, String> { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime.list_approvals(true).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn approve_pending_request( + id: String, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime + .approve_request(id.trim(), Some("desktop-user".to_string())) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn reject_pending_request( + id: String, + reason: Option, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + runtime + .reject_request(id.trim(), reason) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] async fn get_chat_session_history( query: UiSessionHistoryQuery, @@ -647,7 +805,8 @@ async fn start_gateway_inner(state_ref: Arc>) -> Result SetupAppConfig { enabled: config.security.audit.enabled, record_arguments: config.security.audit.record_arguments, }, + require_confirmation: matches!( + config.autonomy.level.as_str(), + "semi" | "supervised" + ), } } @@ -1030,7 +1193,7 @@ fn setup_config_to_core( current.security.audit.enabled = setup.audit.enabled; current.security.audit.record_arguments = setup.audit.record_arguments; - ensure_desktop_automation_capabilities(&mut current); + ensure_desktop_automation_capabilities(&mut current, setup.require_confirmation); current.validate_or_bail().map_err(|e| e.to_string())?; Ok(current) } @@ -1082,7 +1245,7 @@ fn save_config_with_fallback(config: &mut Config) -> Result<(), String> { } } -fn ensure_desktop_automation_capabilities(config: &mut Config) -> bool { +fn ensure_desktop_automation_capabilities(config: &mut Config, require_confirmation: bool) -> bool { let mut changed = false; if !config.browser.enabled { @@ -1113,22 +1276,62 @@ fn ensure_desktop_automation_capabilities(config: &mut Config) -> bool { } } - if config.autonomy.require_approval_for_medium_risk { - config.autonomy.require_approval_for_medium_risk = false; - changed = true; - } + // 高风险工具:开启「执行确认」后不自动批准,由用户在对话中逐项确认。 + let high_risk_tools = ["browser", "shell", "file_write", "file_edit"]; + // 低风险工具始终自动放行,避免读取类操作也打断对话。 + let low_risk_tools = ["file_read"]; - let auto_approved_tools = ["browser", "shell", "file_read", "file_write", "file_edit"]; - for tool in auto_approved_tools { - if !config + if require_confirmation { + // 不确定/高风险操作 → 进入交互确认。 + if config.autonomy.level != "semi" { + config.autonomy.level = "semi".to_string(); + changed = true; + } + if !config.approvals.enabled { + config.approvals.enabled = true; + changed = true; + } + if !config.autonomy.require_approval_for_medium_risk { + config.autonomy.require_approval_for_medium_risk = true; + changed = true; + } + // 从自动批准名单里移除高风险工具。 + let before = config.autonomy.auto_approve.len(); + config .autonomy .auto_approve - .iter() - .any(|existing| existing.eq_ignore_ascii_case(tool)) - { - config.autonomy.auto_approve.push(tool.to_string()); + .retain(|tool| !high_risk_tools.iter().any(|t| t.eq_ignore_ascii_case(tool))); + if config.autonomy.auto_approve.len() != before { + changed = true; + } + for tool in low_risk_tools { + if !config + .autonomy + .auto_approve + .iter() + .any(|existing| existing.eq_ignore_ascii_case(tool)) + { + config.autonomy.auto_approve.push(tool.to_string()); + changed = true; + } + } + } else { + // 本地便捷模式:全部自动放行,不打断。 + if config.autonomy.require_approval_for_medium_risk { + config.autonomy.require_approval_for_medium_risk = false; changed = true; } + for tool in high_risk_tools.iter().chain(low_risk_tools.iter()) { + if !config + .autonomy + .auto_approve + .iter() + .any(|existing| existing.eq_ignore_ascii_case(tool)) + { + config.autonomy.auto_approve.push(tool.to_string()); + changed = true; + } + } } changed @@ -1177,15 +1380,19 @@ fn normalize_optional_string(value: Option) -> Option { }) } +fn user_home_dir() -> Option { + std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) +} + fn expand_tilde_path(value: &str) -> PathBuf { if value == "~" { - return std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(value)); + return user_home_dir().unwrap_or_else(|| PathBuf::from(value)); } if let Some(rest) = value.strip_prefix("~/") { - if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + if let Some(home) = user_home_dir() { return home.join(rest); } } @@ -1249,8 +1456,27 @@ fn display_provider_name(id: &str) -> String { } } +/// Worker-thread stack size for the async runtime that backs Tauri commands. +/// +/// The `Config` struct is large (~8.7 KiB) and very deeply nested, so serde +/// (de)serialization to/from TOML/JSON consumes a lot of stack. On Windows the +/// default worker-thread stack (~1 MiB) overflows during config save, crashing +/// the process with `0xC00000FD` (STATUS_STACK_OVERFLOW). An 8 MiB stack keeps +/// every command handler (which may (de)serialize `Config`) well within bounds. +const ASYNC_RUNTIME_STACK_BYTES: usize = 8 * 1024 * 1024; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Install a tokio runtime with larger worker stacks *before* anything uses + // the async runtime, so all Tauri command handlers run with enough stack. + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(ASYNC_RUNTIME_STACK_BYTES) + .build() + .expect("Failed to build async runtime"); + let runtime: &'static tokio::runtime::Runtime = Box::leak(Box::new(runtime)); + tauri::async_runtime::set(runtime.handle().clone()); + omninova_core::init().expect("Failed to initialize core"); let config = Config::load_or_init().expect("Failed to load config"); @@ -1286,6 +1512,10 @@ pub fn run() { provider_health_overview, route_inbound_message, process_inbound_message, + delete_chat_session, + list_pending_approvals, + approve_pending_request, + reject_pending_request, get_chat_session_history, session_tree_snapshot, check_browser_dep, @@ -1298,6 +1528,13 @@ pub fn run() { skills_package_summary, composer_attachments::read_composer_attachments, desktop_capture::capture_desktop_screenshot, + list_knowledge_documents, + upload_knowledge_excel, + delete_knowledge_document, + list_employees, + save_employee, + set_employee_enabled, + delete_employee, ]) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { diff --git a/apps/omninova-tauri/src-tauri/tauri.conf.json b/apps/omninova-tauri/src-tauri/tauri.conf.json index ac051fa1..0c752816 100644 --- a/apps/omninova-tauri/src-tauri/tauri.conf.json +++ b/apps/omninova-tauri/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "OmniNova Claw", - "version": "0.1.0", + "version": "0.2.0-alpha.2", "identifier": "com.omninova.claw", "build": { "frontendDist": "../dist", diff --git a/apps/omninova-tauri/src/App.css b/apps/omninova-tauri/src/App.css index 62a2b918..ef9eaa6f 100644 --- a/apps/omninova-tauri/src/App.css +++ b/apps/omninova-tauri/src/App.css @@ -271,6 +271,44 @@ color: #b91c1c; } +/* Shared button styles used by Knowledge base and similar panels */ +.setup-primary-btn, +.setup-secondary-btn { + padding: 9px 18px; + border-radius: 10px; + font-size: var(--fs-base); + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s, filter 0.15s; +} + +.setup-primary-btn { + background: var(--accent-blue); + color: #fff; +} + +.setup-primary-btn:hover:not(:disabled) { + filter: brightness(1.06); +} + +.setup-secondary-btn { + background: rgba(255, 255, 255, 0.8); + border-color: var(--border-soft); + color: var(--text-primary); +} + +.setup-secondary-btn:hover:not(:disabled) { + background: #fff; + border-color: rgba(37, 99, 235, 0.35); +} + +.setup-primary-btn:disabled, +.setup-secondary-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .setup-action-hint { margin: 12px 0 0; font-size: 12px; @@ -735,7 +773,7 @@ border: 1px solid rgba(80, 100, 180, 0.18); background: rgba(255, 255, 255, 0.82); color: inherit; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-family: var(--font-mono); font-size: 12px; color: #2a2a3e; } @@ -804,14 +842,14 @@ background: rgba(255, 255, 255, 0.92); color: #1a1a2e; padding: 10px 12px; - font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; + font-family: var(--font-mono); font-size: 12px; } .console-textarea-code, .result-panel, .inline-code-block { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-family: var(--font-mono); } .result-panel { @@ -1490,6 +1528,54 @@ gap: 2px; } +.chat-avatar-row { + display: flex; + align-items: stretch; + gap: 2px; + position: relative; +} + +.chat-avatar-row .chat-avatar-item { + flex: 1; + min-width: 0; +} + +.chat-avatar-delete { + flex-shrink: 0; + width: 26px; + height: 26px; + align-self: center; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #8a8aa8; + font-size: 18px; + line-height: 1; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s, color 0.15s; +} + +.chat-avatar-row:hover .chat-avatar-delete, +.chat-avatar-row:focus-within .chat-avatar-delete { + opacity: 1; + color: #6a6a88; +} + +/* 拆分 :has 选择器,避免在不支持 :has 的 WebView 中使整组规则失效 */ +.chat-avatar-row:has(.chat-avatar-item.is-active) .chat-avatar-delete { + opacity: 1; + color: #6a6a88; +} + +.chat-avatar-delete:hover, +.chat-avatar-delete:focus-visible { + background: rgba(220, 80, 80, 0.14); + color: #d04040; + opacity: 1; +} + .chat-avatar-item { display: flex; align-items: center; @@ -1847,6 +1933,312 @@ line-height: 1.45; } +/* --- Interactive execution confirmation --- */ +.chat-approval-card { + margin-top: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(234, 179, 8, 0.35); + background: rgba(234, 179, 8, 0.08); +} + +.chat-approval-card--done { + border-color: var(--border-soft); + background: rgba(0, 0, 0, 0.03); +} + +.chat-approval-done-text { + font-size: 12px; + color: var(--text-muted); +} + +.chat-approval-head { + font-size: 12px; + font-weight: 600; + color: #92660a; + margin-bottom: 8px; +} + +.chat-approval-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-approval-item { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid var(--border-soft); +} + +.chat-approval-tool { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.chat-approval-tool-name { + font-weight: 600; + font-size: 13px; + color: var(--text-primary); +} + +.chat-approval-reason { + font-size: 11px; + color: var(--text-muted); +} + +.chat-approval-args { + font-family: var(--font-mono); + font-size: 11px; + color: #3a3a52; + background: rgba(0, 0, 0, 0.05); + padding: 4px 8px; + border-radius: 6px; + word-break: break-all; + white-space: pre-wrap; +} + +.chat-approval-actions { + display: flex; + gap: 8px; + margin-top: 2px; +} + +.chat-approval-btn { + padding: 6px 14px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: filter 0.15s, background 0.15s, border-color 0.15s; +} + +.chat-approval-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-approval-btn--approve { + background: var(--accent-blue); + color: #fff; +} + +.chat-approval-btn--approve:hover:not(:disabled) { + filter: brightness(1.06); +} + +.chat-approval-btn--reject { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(192, 64, 64, 0.3); + color: #c04040; +} + +.chat-approval-btn--reject:hover:not(:disabled) { + background: rgba(232, 112, 112, 0.1); +} + +/* --- Digital employees --- */ +.emp-preset-pill { + padding: 6px 12px; + border-radius: var(--radius-pill); + border: 1px dashed rgba(37, 99, 235, 0.35); + background: rgba(255, 255, 255, 0.6); + color: #1d4ed8; + font-size: 12px; + cursor: pointer; +} + +.emp-preset-pill:hover { + background: rgba(37, 99, 235, 0.08); +} + +.emp-group-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin: 0 0 6px 2px; +} + +.emp-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border-soft); + border-radius: 12px; + background: var(--surface-elevated); +} + +.emp-card--disabled { + opacity: 0.6; +} + +.emp-card-main { + min-width: 0; +} + +.emp-card-title { + display: flex; + align-items: center; + gap: 8px; +} + +.emp-badge { + font-size: 10px; + padding: 2px 8px; + border-radius: var(--radius-pill); + background: rgba(0, 0, 0, 0.06); + color: var(--text-muted); +} + +.emp-card-desc { + margin: 0.35rem 0 0; + font-size: 0.85rem; + color: var(--text-primary); +} + +.emp-card-meta { + margin: 0.3rem 0 0; + font-size: 0.78rem; + color: var(--text-muted); +} + +.emp-card-actions { + display: flex; + gap: 0.4rem; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; +} + +.emp-card-actions button { + padding: 6px 12px; + font-size: 12px; +} + +/* --- Interactive ask-user prompt --- */ +.chat-prompt-card { + margin-top: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(37, 99, 235, 0.28); + background: rgba(37, 99, 235, 0.06); +} + +.chat-prompt-card--done { + border-color: var(--border-soft); + background: rgba(0, 0, 0, 0.03); +} + +.chat-prompt-done-text { + font-size: 12px; + color: var(--text-muted); +} + +.chat-prompt-head { + font-size: 12px; + font-weight: 600; + color: #1d4ed8; + margin-bottom: 6px; +} + +.chat-prompt-question { + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + white-space: pre-wrap; +} + +.chat-prompt-context { + margin-top: 6px; + padding: 6px 8px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.04); + font-size: 11px; + color: var(--text-muted); + white-space: pre-wrap; +} + +.chat-prompt-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.chat-prompt-option { + padding: 7px 14px; + border-radius: var(--radius-pill); + border: 1px solid rgba(37, 99, 235, 0.35); + background: rgba(255, 255, 255, 0.9); + color: #1d4ed8; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.chat-prompt-option:hover:not(:disabled) { + background: var(--accent-blue); + color: #fff; + transform: translateY(-1px); +} + +.chat-prompt-option:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-prompt-free { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.chat-prompt-free-input { + flex: 1; + min-width: 0; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: #fff; + color: var(--text-primary); + font-size: 13px; +} + +.chat-prompt-free-input:focus { + outline: none; + border-color: rgba(37, 99, 235, 0.45); +} + +.chat-prompt-free-send { + flex-shrink: 0; + padding: 7px 14px; + border-radius: 8px; + border: 1px solid transparent; + background: var(--accent-blue); + color: #fff; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.chat-prompt-free-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .chat-input-row.chat-input-row--drag-over { outline: 2px dashed rgba(37, 99, 235, 0.42); outline-offset: -2px; diff --git a/apps/omninova-tauri/src/App.tsx b/apps/omninova-tauri/src/App.tsx index 6211154b..4c6d9d6a 100644 --- a/apps/omninova-tauri/src/App.tsx +++ b/apps/omninova-tauri/src/App.tsx @@ -2,10 +2,15 @@ import { useState } from "react"; import "./App.css"; import { AppShell, type AppNavId } from "./components/AppShell"; import { Chat } from "./components/Chat/Chat"; +import { KnowledgeBase } from "./components/Knowledge/KnowledgeBase"; +import { Employees } from "./components/Employees/Employees"; import { Setup } from "./components/Setup/Setup"; function App() { const [nav, setNav] = useState("chat"); + const [employeeContext, setEmployeeContext] = useState<{ id: string; name: string } | null>( + null + ); return (
@@ -13,6 +18,16 @@ function App() { {nav === "chat" || nav === "cron" ? ( + ) : nav === "knowledge" ? ( + + ) : nav === "employees" ? ( + { + setEmployeeContext({ ...emp }); + setNav("chat"); + }} /> ) : ( ); + case "book": + return ( + + + + + + ); + case "users": + return ( + + + + + + ); default: return null; } diff --git a/apps/omninova-tauri/src/components/Chat/Chat.tsx b/apps/omninova-tauri/src/components/Chat/Chat.tsx index b59bf156..dd5b5bb5 100644 --- a/apps/omninova-tauri/src/components/Chat/Chat.tsx +++ b/apps/omninova-tauri/src/components/Chat/Chat.tsx @@ -8,10 +8,12 @@ import { } from "../../utils/composerAttachments"; import { areStoredMessagesEqual, + deleteChatSessionOnGateway, fetchSessionHistory, fetchWebSessionsFromGateway, formatTime, loadChatStorage, + markSessionDeleted, mergeAvatarSessions, saveChatStorage, type StoredAvatarSession, @@ -24,6 +26,8 @@ import type { GatewayStatus, ProviderHealthSummary, ExecutionStep, + PendingApprovalView, + UserPromptView, RouteDecision, } from "../../types/config"; @@ -195,6 +199,13 @@ async function formatDroppedFilesContent(files: FileList | readonly File[]): Pro interface ChatMessage extends StoredChatMessage { steps?: ExecutionStep[]; + pendingApprovals?: PendingApprovalView[]; + /** 已被用户处理(批准/拒绝)的审批 id,避免重复点击。 */ + resolvedApprovalIds?: string[]; + /** Agent 主动发起的提问(交互执行)。 */ + userPrompt?: UserPromptView | null; + /** 该提问是否已被用户回答,回答后隐藏快捷选项。 */ + userPromptAnswered?: boolean; } type SidebarTab = "avatars" | "channels" | "scheduled"; @@ -226,9 +237,11 @@ const IM_PLATFORM_OPTIONS = [ interface ChatProps { /** 与侧栏「定时任务」入口同步 */ initialSidebarTab?: SidebarTab; + /** 数字员工会话上下文:进入后该会话以此员工的人设/技能运行 */ + employeeContext?: { id: string; name: string } | null; } -export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { +export function Chat({ initialSidebarTab = "avatars", employeeContext = null }: ChatProps) { const initialStorage = useMemo(() => loadChatStorage(), []); const [avatars, setAvatars] = useState(initialStorage.avatars); const [activeAvatarId, setActiveAvatarId] = useState(initialStorage.activeAvatarId); @@ -273,6 +286,25 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { setSidebarTab(initialSidebarTab); }, [initialSidebarTab]); + // 进入数字员工会话:为该员工建立/复用专属会话并切换到它。 + useEffect(() => { + if (!employeeContext) return; + const avatarId = `emp-${employeeContext.id}`; + const sessionId = `employee-${employeeContext.id}`; + setAvatars((prev) => { + if (prev.some((a) => a.id === avatarId)) { + return prev.map((a) => + a.id === avatarId ? { ...a, name: employeeContext.name } : a + ); + } + return [ + { id: avatarId, name: employeeContext.name, sessionId, lastAt: formatTime(new Date()) }, + ...prev, + ]; + }); + setActiveAvatarId(avatarId); + }, [employeeContext]); + const loadSessionHistory = useCallback( async ( avatarId: string, @@ -421,6 +453,56 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { setActiveAvatarId(id); }; + const handleDeleteAvatar = useCallback( + async (avatarId: string) => { + const target = avatars.find((a) => a.id === avatarId); + if (!target) return; + if (!window.confirm(`确定删除会话「${target.name}」?\n\n将清除该会话的本地与网关历史记录。`)) { + return; + } + + markSessionDeleted(target.sessionId); + if (gatewayStatus === "connected") { + try { + await deleteChatSessionOnGateway(target.sessionId); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return; + } + } + + setMessagesBySession((prev) => { + const next = { ...prev }; + delete next[avatarId]; + return next; + }); + + const filtered = avatars.filter((a) => a.id !== avatarId); + const nextAvatars: StoredAvatarSession[] = + filtered.length > 0 + ? filtered + : (() => { + const id = `avatar-${Date.now()}`; + return [ + { + id, + name: "对话 1", + sessionId: `session-${id}`, + lastAt: formatTime(new Date()), + }, + ]; + })(); + + setAvatars(nextAvatars); + if (activeAvatarId === avatarId) { + setActiveAvatarId(nextAvatars[0]?.id ?? "main"); + } + + void syncChatSessions(); + }, + [activeAvatarId, avatars, gatewayStatus, syncChatSessions] + ); + const handleRefreshHistory = useCallback(() => { void refreshGatewayStatus(); if (gatewayStatus === "connected") { @@ -490,8 +572,9 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { }); }, []); - const handleSend = async () => { - const text = input.trim(); + const handleSend = async (overrideText?: string) => { + const isResume = typeof overrideText === "string"; + const text = (overrideText ?? input).trim(); if (!text || sending) return; if (gatewayStatus !== "connected") { @@ -499,7 +582,9 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { return; } - setInput(""); + if (!isResume) { + setInput(""); + } setError(null); cancelledRef.current = false; setElapsedSec(0); @@ -530,6 +615,10 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { preferred_provider: selectedModel === "auto" ? undefined : selectedModel, }; + if (activeAvatarId.startsWith("emp-")) { + metadata.employee_id = activeAvatarId.slice("emp-".length); + } + if (desktopVisionOn && desktopVisionMaster && isTauriEnvironment()) { updateStep("桌面视觉", "running", "正在截取主屏幕…"); try { @@ -556,7 +645,7 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { ...prev, [activeAvatarId]: (prev[activeAvatarId] ?? []).slice(0, -1), })); - setInput(text); + if (!isResume) setInput(text); return; } } @@ -590,12 +679,14 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { ...prev, [activeAvatarId]: (prev[activeAvatarId] ?? []).slice(0, -1), })); - setInput(text); + if (!isResume) setInput(text); return; } const replyText = result?.reply || "(空回复)"; const steps = result?.steps?.length ? result.steps : activeSteps; + const pendingApprovals = result?.pending_approvals ?? []; + const userPrompt = result?.user_prompt ?? null; setActiveSteps(steps); setMessagesBySession((prev) => ({ ...prev, @@ -606,6 +697,8 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { content: replyText, agent: result?.route?.agent_name, steps, + pendingApprovals, + userPrompt, }, ], })); @@ -615,7 +708,7 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { ...prev, [activeAvatarId]: (prev[activeAvatarId] ?? []).slice(0, -1), })); - setInput(text); + if (!isResume) setInput(text); return; } @@ -641,6 +734,81 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { } }; + const markApprovalResolved = useCallback( + (messageIndex: number, approvalId: string) => { + setMessagesBySession((prev) => { + const list = prev[activeAvatarId] ?? []; + const target = list[messageIndex]; + if (!target) return prev; + const next = list.slice(); + next[messageIndex] = { + ...target, + resolvedApprovalIds: [ + ...(target.resolvedApprovalIds ?? []), + approvalId, + ], + }; + return { ...prev, [activeAvatarId]: next }; + }); + }, + [activeAvatarId] + ); + + const handleApproveExecution = useCallback( + async (messageIndex: number, approval: PendingApprovalView) => { + if (sending) return; + try { + await invokeTauri("approve_pending_request", { id: approval.id }); + } catch (e) { + setError(`批准失败:${e instanceof Error ? e.message : String(e)}`); + return; + } + markApprovalResolved(messageIndex, approval.id); + void handleSend( + `(已批准操作「${approval.tool_name}:${approval.args_summary}」,请继续执行。)` + ); + }, + [sending, markApprovalResolved] + ); + + const handleRejectExecution = useCallback( + async (messageIndex: number, approval: PendingApprovalView) => { + if (sending) return; + try { + await invokeTauri("reject_pending_request", { + id: approval.id, + reason: "用户在对话中拒绝执行", + }); + } catch (e) { + setError(`拒绝失败:${e instanceof Error ? e.message : String(e)}`); + return; + } + markApprovalResolved(messageIndex, approval.id); + void handleSend( + `(我拒绝执行操作「${approval.tool_name}:${approval.args_summary}」,请不要执行该操作,改用其它方式或先向我说明原因。)` + ); + }, + [sending, markApprovalResolved] + ); + + const handleAnswerPrompt = useCallback( + (messageIndex: number, answer: string) => { + if (sending) return; + const text = answer.trim(); + if (!text) return; + setMessagesBySession((prev) => { + const list = prev[activeAvatarId] ?? []; + const target = list[messageIndex]; + if (!target) return prev; + const next = list.slice(); + next[messageIndex] = { ...target, userPromptAnswered: true }; + return { ...prev, [activeAvatarId]: next }; + }); + void handleSend(text); + }, + [sending, activeAvatarId] + ); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -818,7 +986,10 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) {

会话

    {avatars.map((a) => ( -
  • +
  • +
  • ))}
@@ -1020,6 +1203,23 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) {
Agent: {msg.agent}
)} {msg.steps?.length ? : null} + {msg.pendingApprovals?.length ? ( + void handleApproveExecution(i, approval)} + onReject={(approval) => void handleRejectExecution(i, approval)} + /> + ) : null} + {msg.userPrompt ? ( + handleAnswerPrompt(i, answer)} + /> + ) : null}
)) )} @@ -1184,6 +1384,141 @@ export function Chat({ initialSidebarTab = "avatars" }: ChatProps) { ); } +function ApprovalConfirm({ + approvals, + resolvedIds, + busy, + onApprove, + onReject, +}: { + approvals: PendingApprovalView[]; + resolvedIds: string[]; + busy: boolean; + onApprove: (approval: PendingApprovalView) => void; + onReject: (approval: PendingApprovalView) => void; +}) { + const pending = approvals.filter((a) => !resolvedIds.includes(a.id)); + if (pending.length === 0) { + return ( +
+ 已处理上述确认请求。 +
+ ); + } + return ( +
+
需要你确认后再执行
+
    + {pending.map((approval) => ( +
  • +
    + {approval.tool_name} + {approval.reason} +
    + {approval.args_summary ? ( + {approval.args_summary} + ) : null} +
    + + +
    +
  • + ))} +
+
+ ); +} + +function UserPromptCard({ + prompt, + answered, + busy, + onAnswer, +}: { + prompt: UserPromptView; + answered: boolean; + busy: boolean; + onAnswer: (answer: string) => void; +}) { + const [freeText, setFreeText] = useState(""); + const options = prompt.options ?? []; + const allowFreeText = prompt.allow_free_text ?? true; + + if (answered) { + return ( +
+ 已回复,继续执行中… +
+ ); + } + + return ( +
+
需要你来决定
+
{prompt.question}
+ {prompt.context ? ( +
{prompt.context}
+ ) : null} + {options.length > 0 ? ( +
+ {options.map((option, idx) => ( + + ))} +
+ ) : null} + {allowFreeText ? ( +
{ + e.preventDefault(); + const text = freeText.trim(); + if (!text) return; + setFreeText(""); + onAnswer(text); + }} + > + 0 ? "或自由作答…" : "输入你的回答…"} + onChange={(e) => setFreeText(e.target.value)} + /> + +
+ ) : null} +
+ ); +} + function ExecutionSteps({ steps }: { steps: ExecutionStep[] }) { const statusLabel = (status?: ExecutionStep["status"]) => { switch (status) { diff --git a/apps/omninova-tauri/src/components/Employees/Employees.tsx b/apps/omninova-tauri/src/components/Employees/Employees.tsx new file mode 100644 index 00000000..fa2014fc --- /dev/null +++ b/apps/omninova-tauri/src/components/Employees/Employees.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { invokeTauri, isTauriEnvironment } from "../../utils/tauri"; +import type { EmployeeManifest, EmployeeSummary } from "../../types/config"; + +interface EmployeesProps { + /** 点击「会话」时回调,由上层切换到聊天并注入该员工上下文 */ + onOpenSession?: (employee: { id: string; name: string }) => void; +} + +interface EditorState { + id: string; + name: string; + description: string; + prompt: string; + type: string; + mcpText: string; +} + +const EMPTY_EDITOR: EditorState = { + id: "", + name: "", + description: "", + prompt: "", + type: "", + mcpText: "", +}; + +const PRESETS: Array<{ name: string; type: string; description: string; prompt: string }> = [ + { + name: "SRE 运维专家", + type: "运维", + description: "专注监控分析、告警处置、K8s 故障排查", + prompt: + "你是一名资深 SRE 运维专家。聚焦稳定性、监控告警分析、Kubernetes 与容器故障排查。回答克制、专业、给出可执行的排查步骤与命令;遇到高风险操作先确认。只做运维分内之事,不越界。", + }, + { + name: "DevOps 流水线顾问", + type: "运维", + description: "专注 CI/CD、Ansible、Terraform 自动化", + prompt: + "你是一名 DevOps 顾问。聚焦 CI/CD 流水线、基础设施即代码(Terraform)、配置管理(Ansible)。给出规范、可复用的流水线与脚本建议,强调幂等与回滚。", + }, + { + name: "数据库 DBA", + type: "运维", + description: "专注数据库性能、慢查询、备份恢复", + prompt: + "你是一名数据库管理专家(DBA)。聚焦慢查询优化、索引设计、主从与备份恢复。先看执行计划与指标再给结论,谨慎对待任何写操作与变更。", + }, +]; + +export function Employees({ onOpenSession }: EmployeesProps) { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editor, setEditor] = useState(null); + + const refresh = useCallback(async () => { + if (!isTauriEnvironment()) { + setError("数字员工管理需在桌面应用中打开"); + return; + } + setLoading(true); + setError(null); + try { + const items = await invokeTauri("list_employees"); + setList(items); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const startCreate = (preset?: (typeof PRESETS)[number]) => + setEditor( + preset + ? { ...EMPTY_EDITOR, name: preset.name, type: preset.type, description: preset.description, prompt: preset.prompt } + : { ...EMPTY_EDITOR } + ); + + const startEdit = (emp: EmployeeSummary) => + setEditor({ + id: emp.id, + name: emp.name, + description: emp.description, + prompt: emp.prompt, + type: emp.type === "其它" ? "" : emp.type, + mcpText: "", + }); + + const handleSave = async () => { + if (!editor) return; + if (!editor.name.trim()) { + setError("请填写名称"); + return; + } + let mcpServers: unknown = {}; + if (editor.mcpText.trim()) { + try { + mcpServers = JSON.parse(editor.mcpText); + } catch { + setError("MCP 配置不是合法 JSON"); + return; + } + } + const manifest: EmployeeManifest = { + id: editor.id, + name: editor.name.trim(), + description: editor.description.trim(), + prompt: editor.prompt, + enabled: true, + type: editor.type.trim(), + mcp_servers: mcpServers, + }; + setError(null); + try { + await invokeTauri("save_employee", { manifest }); + setEditor(null); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const handleToggle = async (emp: EmployeeSummary) => { + try { + await invokeTauri("set_employee_enabled", { + id: emp.id, + enabled: !emp.enabled, + }); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const handleDelete = async (emp: EmployeeSummary) => { + if (!window.confirm(`确定删除数字员工「${emp.name}」?将同时删除其专属技能。`)) return; + try { + await invokeTauri("delete_employee", { id: emp.id }); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const grouped = useMemo(() => { + const map = new Map(); + for (const emp of list) { + const key = emp.type || "其它"; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(emp); + } + return [...map.entries()]; + }, [list]); + + return ( +
+
+

数字员工

+

+ 为不同垂直场景(如 SRE 运维专家DevOps 顾问DBA)创建专属对话角色, + 每个员工有独立人设、精简技能与专属 MCP(运维场景如 Prometheus / K8s)。 + 点击「会话」即以该角色开启聚焦、专业的对话,降低 token 消耗与技能干扰。 +

+
+ +
+
+ + +
+
+ 快速模板: + {PRESETS.map((p) => ( + + ))} +
+ {error &&

{error}

} +
+ + {editor && ( +
+

{editor.id ? `修改:${editor.name}` : "新增数字员工"}

+
+ + + +