chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Rust build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "genericagent-web2",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
Generated
+5416
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ga-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "5"
|
||||
rfd = "0.15"
|
||||
|
||||
[lib]
|
||||
name = "ga_desktop_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
@@ -0,0 +1,25 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Build identity used to decide whether a bridge already holding :14168 belongs to THIS
|
||||
// build. commit hash + build timestamp → distinct on every build, even when the human
|
||||
// version in tauri.conf.json is unchanged (so same-version re-publishes still take over
|
||||
// a stale bridge). The bridge reports this back via GET /services/identity.
|
||||
let commit = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "nogit".to_string());
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
println!("cargo:rustc-env=GA_BUILD_ID={}-{}", commit, stamp);
|
||||
// Re-run when the checked-out commit changes so the id stays fresh.
|
||||
println!("cargo:rerun-if-changed=../../../.git/HEAD");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for all windows",
|
||||
"windows": ["main", "setup"],
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:14168", "http://localhost:14168"]
|
||||
},
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-get-all-windows",
|
||||
"core:webview:default",
|
||||
"allow-start-bridge",
|
||||
"allow-start-bridge-with-config",
|
||||
"allow-get-config",
|
||||
"allow-export-mykey",
|
||||
"allow-shortcut-should-ask",
|
||||
"allow-shortcut-decide"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,29 @@
|
||||
[[permission]]
|
||||
identifier = "allow-start-bridge"
|
||||
description = "Spawn bridge from the services panel when bridge was stopped."
|
||||
commands.allow = ["start_bridge"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-start-bridge-with-config"
|
||||
description = "Spawn bridge from setup with explicit python/project paths."
|
||||
commands.allow = ["start_bridge_with_config"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-get-config"
|
||||
description = "Read saved bridge spawn configuration."
|
||||
commands.allow = ["get_config"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-export-mykey"
|
||||
description = "Save mykey.py via native file dialog."
|
||||
commands.allow = ["export_mykey"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-shortcut-should-ask"
|
||||
description = "Check whether to show the first-run desktop-shortcut prompt."
|
||||
commands.allow = ["shortcut_should_ask"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-shortcut-decide"
|
||||
description = "Persist the user's desktop-shortcut choice and create it if enabled."
|
||||
commands.allow = ["shortcut_decide"]
|
||||
@@ -0,0 +1,908 @@
|
||||
use std::process::{Command, Child, Stdio};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::Mutex;
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::thread;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
static BRIDGE_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
|
||||
|
||||
/// Get project root (parent of frontends/)
|
||||
fn project_root() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.expect("cannot get exe path")
|
||||
.parent().expect("cannot get exe dir") // frontends/
|
||||
.parent().expect("cannot get project root") // project root
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
/// Directory next to which a self-contained bundle keeps its runtime/ folder.
|
||||
/// Windows: the exe's folder. Linux: the .AppImage's folder ($APPIMAGE) when launched as an
|
||||
/// AppImage (current_exe would otherwise point inside the read-only squashfs mount).
|
||||
/// macOS portable package: the folder containing GenericAgent.app and runtime/.
|
||||
fn bundle_anchor_dir() -> Option<PathBuf> {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(p) = std::env::var_os("APPIMAGE") {
|
||||
if let Some(d) = PathBuf::from(p).parent() {
|
||||
return Some(d.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// current_exe() inside a bundle is:
|
||||
// <package>/GenericAgent.app/Contents/MacOS/GenericAgent
|
||||
// Prefer the standard macOS layout where runtime is embedded in the app:
|
||||
// GenericAgent.app/Contents/Resources/runtime/app/agentmain.py
|
||||
// Fall back to the old portable layout for compatibility:
|
||||
// <package>/runtime/app/agentmain.py
|
||||
let mut d = exe.parent();
|
||||
while let Some(dir) = d {
|
||||
if dir.extension().and_then(|s| s.to_str()) == Some("app") {
|
||||
let resources = dir.join("Contents").join("Resources");
|
||||
if resources.join("runtime").join("app").join("agentmain.py").exists() {
|
||||
return Some(resources);
|
||||
}
|
||||
if let Some(parent) = dir.parent() {
|
||||
return Some(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
d = dir.parent();
|
||||
}
|
||||
}
|
||||
|
||||
Some(exe.parent()?.to_path_buf())
|
||||
}
|
||||
|
||||
/// Embedded interpreter inside the bundle's runtime/python (base python, before venv).
|
||||
fn bundle_python() -> Option<PathBuf> {
|
||||
let root = bundle_root()?;
|
||||
#[cfg(windows)]
|
||||
let p = root.join("python").join("python.exe");
|
||||
#[cfg(not(windows))]
|
||||
let p = root.join("python").join("bin").join("python3");
|
||||
if p.exists() { Some(p) } else { None }
|
||||
}
|
||||
|
||||
/// Find python executable:
|
||||
/// 1. The embedded bundle python (runtime/python) — deps are installed directly into it
|
||||
/// (no venv), and its path is resolved relative to the bundle anchor at runtime, so the
|
||||
/// package stays relocatable (moving the folder doesn't break absolute venv paths).
|
||||
/// 2. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
|
||||
/// 3. Fallback to system PATH
|
||||
fn find_python() -> String {
|
||||
if let Some(p) = bundle_python() {
|
||||
return p.to_string_lossy().to_string();
|
||||
}
|
||||
let root = project_root();
|
||||
let portable_python_dir = root.join(".portable").join("uv-python");
|
||||
|
||||
if portable_python_dir.exists() {
|
||||
// uv installs python like: uv-python/cpython-3.12.x-windows-x86_64/python.exe
|
||||
// We need to search for python.exe inside subdirectories
|
||||
if let Ok(entries) = std::fs::read_dir(&portable_python_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let py = path.join("python.exe");
|
||||
if py.exists() {
|
||||
return py.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let py = path.join("bin").join("python3");
|
||||
if py.exists() {
|
||||
return py.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: system PATH
|
||||
#[cfg(windows)]
|
||||
{ "python".to_string() }
|
||||
#[cfg(not(windows))]
|
||||
{ "python3".to_string() }
|
||||
}
|
||||
|
||||
/// Find the project directory (folder containing agentmain.py).
|
||||
/// Bundle layout: <exe dir>/runtime/app/agentmain.py. Dev layout: walk up from the exe.
|
||||
fn find_project_dir() -> Option<String> {
|
||||
// Bundle layout: source tucked under <anchor>/runtime/app/
|
||||
if let Some(anchor) = bundle_anchor_dir() {
|
||||
let app = anchor.join("runtime").join("app");
|
||||
if app.join("agentmain.py").exists() {
|
||||
return Some(app.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Dev/source layout: walk up to 8 levels from the exe location.
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let mut dir = Some(exe.parent()?);
|
||||
for _ in 0..8 {
|
||||
match dir {
|
||||
Some(d) => {
|
||||
if d.join("agentmain.py").exists() {
|
||||
return Some(d.to_string_lossy().to_string());
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Settings file path: ~/.ga_desktop_settings.json
|
||||
fn settings_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ga_desktop_settings.json")
|
||||
}
|
||||
|
||||
/// Read the settings file as a JSON object (empty object when missing/unparseable).
|
||||
fn read_settings() -> serde_json::Map<String, serde_json::Value> {
|
||||
let path = settings_path();
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(serde_json::Value::Object(m)) = serde_json::from_str(&content) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
serde_json::Map::new()
|
||||
}
|
||||
|
||||
/// Merge `updates` into the existing settings file and write it back, preserving any keys
|
||||
/// we don't touch. The old code rewrote the file with only python_path/project_dir, which
|
||||
/// would silently drop sibling keys like `desktop_shortcut`. Always go through here.
|
||||
fn merge_settings(updates: serde_json::Value) {
|
||||
let mut obj = read_settings();
|
||||
if let serde_json::Value::Object(m) = updates {
|
||||
for (k, v) in m {
|
||||
obj.insert(k, v);
|
||||
}
|
||||
}
|
||||
let val = serde_json::Value::Object(obj);
|
||||
if let Ok(text) = serde_json::to_string_pretty(&val) {
|
||||
let _ = std::fs::write(settings_path(), text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop-shortcut preference stored in settings under `desktop_shortcut`.
|
||||
/// None = never asked (first run)
|
||||
/// Some(true)/Some(false) = user's remembered choice.
|
||||
fn read_shortcut_pref() -> Option<bool> {
|
||||
read_settings().get("desktop_shortcut").and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
fn write_shortcut_pref(enabled: bool) {
|
||||
merge_settings(serde_json::json!({ "desktop_shortcut": enabled }));
|
||||
}
|
||||
|
||||
/// Create (or overwrite) a desktop shortcut pointing at the CURRENT exe. Overwriting on every
|
||||
/// enabled launch is what makes the portable bundle relocatable: move the folder, relaunch, and
|
||||
/// the shortcut is rewritten to the new path. Windows-only (uses a .lnk via WScript.Shell).
|
||||
#[cfg(windows)]
|
||||
fn ensure_desktop_shortcut() {
|
||||
let Ok(exe) = std::env::current_exe() else { return; };
|
||||
let Some(desktop) = dirs::desktop_dir() else { return; };
|
||||
let lnk = desktop.join("GenericAgent.lnk");
|
||||
let work_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| exe.clone());
|
||||
|
||||
let exe_s = exe.to_string_lossy().replace('\'', "''");
|
||||
let lnk_s = lnk.to_string_lossy().replace('\'', "''");
|
||||
let work_s = work_dir.to_string_lossy().replace('\'', "''");
|
||||
|
||||
// Build the shortcut via WScript.Shell COM, consistent with the existing powershell usage
|
||||
// elsewhere in this file. No extra crate needed.
|
||||
let script = format!(
|
||||
"$ws = New-Object -ComObject WScript.Shell; \
|
||||
$sc = $ws.CreateShortcut('{lnk}'); \
|
||||
$sc.TargetPath = '{exe}'; \
|
||||
$sc.WorkingDirectory = '{work}'; \
|
||||
$sc.IconLocation = '{exe}'; \
|
||||
$sc.Save()",
|
||||
lnk = lnk_s, exe = exe_s, work = work_s
|
||||
);
|
||||
|
||||
let mut cmd = Command::new("powershell.exe");
|
||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
let _ = cmd.status();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_desktop_shortcut() {
|
||||
// Launch target: the AppImage path when running as one, else the current exe. Writing the
|
||||
// current path on every enabled launch keeps a relocated bundle's launcher valid.
|
||||
let Some(target) = std::env::var_os("APPIMAGE").map(PathBuf::from)
|
||||
.or_else(|| std::env::current_exe().ok()) else { return; };
|
||||
let exec = target.to_string_lossy().replace('"', "");
|
||||
// Linux .desktop Icon= needs an image file (or themed name), not the AppImage path. The CI
|
||||
// ships GenericAgent.png next to the AppImage; fall back to a generic themed icon otherwise.
|
||||
let icon = bundle_anchor_dir()
|
||||
.map(|d| d.join("GenericAgent.png"))
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "application-x-executable".to_string());
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\nType=Application\nName=GenericAgent\nComment=GenericAgent Desktop\n\
|
||||
Exec=\"{exec}\"\nIcon={icon}\nTerminal=false\nCategories=Utility;Development;\n",
|
||||
exec = exec, icon = icon
|
||||
);
|
||||
let write_desktop = |path: &std::path::Path| {
|
||||
if std::fs::write(path, &entry).is_ok() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
};
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let apps = home.join(".local/share/applications");
|
||||
let _ = std::fs::create_dir_all(&apps);
|
||||
write_desktop(&apps.join("GenericAgent.desktop"));
|
||||
}
|
||||
if let Some(desktop) = dirs::desktop_dir() {
|
||||
let _ = std::fs::create_dir_all(&desktop);
|
||||
let f = desktop.join("GenericAgent.desktop");
|
||||
write_desktop(&f);
|
||||
// GNOME marks unknown launchers "untrusted"; flag ours so it runs on double-click. Best effort.
|
||||
let _ = Command::new("gio")
|
||||
.args(["set", &f.to_string_lossy(), "metadata::trusted", "true"])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ensure_desktop_shortcut() {
|
||||
// The .app is the launchable unit; drop a symlink to it on the Desktop.
|
||||
let Ok(exe) = std::env::current_exe() else { return; };
|
||||
let mut app: Option<PathBuf> = None;
|
||||
let mut d = exe.parent();
|
||||
while let Some(dir) = d {
|
||||
if dir.extension().and_then(|s| s.to_str()) == Some("app") { app = Some(dir.to_path_buf()); break; }
|
||||
d = dir.parent();
|
||||
}
|
||||
let (Some(app), Some(desktop)) = (app, dirs::desktop_dir()) else { return; };
|
||||
let link = desktop.join("GenericAgent.app");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
let _ = std::os::unix::fs::symlink(&app, &link);
|
||||
}
|
||||
|
||||
#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))]
|
||||
fn ensure_desktop_shortcut() {}
|
||||
|
||||
/// First-run shortcut handling for portable bundles (all platforms). Self-heals the shortcut
|
||||
/// path on every enabled launch (cheap, no UI). The first-run ASK is driven by the frontend
|
||||
/// (see the `shortcut_should_ask` / `shortcut_decide` commands): a native dialog from this
|
||||
/// background startup thread has no parent window and gets buried behind the main window on
|
||||
/// first launch, so the prompt is owned by the web UI instead, which always renders on top.
|
||||
fn maybe_setup_shortcut() {
|
||||
if bundle_root().is_none() {
|
||||
return;
|
||||
}
|
||||
// Only self-heal when the user already opted in. Never prompt here.
|
||||
if read_shortcut_pref() == Some(true) {
|
||||
ensure_desktop_shortcut();
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend asks whether to show the first-run "create desktop shortcut?" prompt.
|
||||
/// True only on a portable bundle whose preference has never been set.
|
||||
#[tauri::command]
|
||||
fn shortcut_should_ask() -> bool {
|
||||
bundle_root().is_some() && read_shortcut_pref().is_none()
|
||||
}
|
||||
|
||||
/// Frontend reports the user's choice. Persists it and creates the shortcut when enabled.
|
||||
#[tauri::command]
|
||||
fn shortcut_decide(create: bool) {
|
||||
write_shortcut_pref(create);
|
||||
if create {
|
||||
ensure_desktop_shortcut();
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this binary is running from inside a macOS .app bundle (packaged build).
|
||||
/// Used to refuse stale ~/.ga_desktop_settings.json that could point at an old checkout
|
||||
/// when App Translocation hides our own runtime/ from current_exe().
|
||||
#[cfg(target_os = "macos")]
|
||||
fn running_inside_app_bundle() -> bool {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| {
|
||||
p.components().any(|c| {
|
||||
c.as_os_str().to_string_lossy().ends_with(".app")
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read config from settings file, or auto-discover and save.
|
||||
/// Self-contained bundles always prefer their own runtime/app over stale user settings,
|
||||
/// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout.
|
||||
pub fn get_or_discover_config() -> (String, String) {
|
||||
let path = settings_path();
|
||||
|
||||
if bundle_root().is_some() {
|
||||
let python = find_python();
|
||||
let project = find_project_dir().unwrap_or_default();
|
||||
if !python.is_empty() && !project.is_empty() {
|
||||
merge_settings(serde_json::json!({
|
||||
"python_path": python,
|
||||
"project_dir": project
|
||||
}));
|
||||
return (python, project);
|
||||
}
|
||||
}
|
||||
|
||||
// Try reading existing settings.
|
||||
// On macOS, a packaged .app must never trust ~/.ga_desktop_settings.json: App
|
||||
// Translocation can run the bundle from a random read-only copy where bundle_root()
|
||||
// fails to see our own runtime/, and an old settings file would then silently point
|
||||
// the bridge at a previously installed checkout. In that case fall through to
|
||||
// auto-discovery (which still resolves the bundle via .app-relative search below).
|
||||
#[cfg(target_os = "macos")]
|
||||
let trust_settings = !running_inside_app_bundle();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let trust_settings = true;
|
||||
|
||||
if trust_settings && path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let python = val.get("python_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let project = val.get("project_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !python.is_empty() && !project.is_empty() {
|
||||
return (python, project);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discover
|
||||
let python = find_python();
|
||||
let project = find_project_dir().unwrap_or_default();
|
||||
|
||||
// Save discovered config
|
||||
if !python.is_empty() && !project.is_empty() {
|
||||
merge_settings(serde_json::json!({
|
||||
"python_path": python,
|
||||
"project_dir": project
|
||||
}));
|
||||
}
|
||||
|
||||
(python, project)
|
||||
}
|
||||
|
||||
/// Self-contained bundle support dir: holds python/, wheels/, install_windows.ps1 and app/.
|
||||
/// Typical portable layout keeps only the exe (+README) at the top level and tucks everything
|
||||
/// else under <exe dir>/runtime/. Returns None when this is not a bundle (e.g. dev build).
|
||||
fn bundle_root() -> Option<PathBuf> {
|
||||
let runtime = bundle_anchor_dir()?.join("runtime");
|
||||
if runtime.join("app").join("agentmain.py").exists() {
|
||||
return Some(runtime);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Marker written after a successful offline prepare. Lives under runtime/ so it travels
|
||||
/// with the bundle: a relocated folder stays "prepared" (deps live in the embedded python,
|
||||
/// which is itself relocatable) and won't re-run prepare.
|
||||
fn prepared_marker() -> Option<PathBuf> {
|
||||
Some(bundle_root()?.join(".prepared"))
|
||||
}
|
||||
|
||||
/// True when this is a self-contained bundle whose python env has not been prepared yet
|
||||
/// (embedded python present but deps not yet installed into it).
|
||||
fn needs_first_run_prepare(project_dir: &str) -> bool {
|
||||
if project_dir.is_empty() { return false; }
|
||||
bundle_python().is_some() && prepared_marker().map(|m| !m.exists()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear env vars a host launcher injects pointing at its own runtime. The Linux AppImage exports
|
||||
/// PYTHONHOME/PYTHONPATH (-> bundled python crashes with "No module named 'encodings'") and
|
||||
/// LD_LIBRARY_PATH (-> wrong shared libs). Our bundled python / prepare / bridge must run clean.
|
||||
fn sanitize_bundle_env(cmd: &mut Command) {
|
||||
cmd.env_remove("PYTHONHOME");
|
||||
cmd.env_remove("PYTHONPATH");
|
||||
cmd.env_remove("LD_LIBRARY_PATH");
|
||||
// Stamp the bridge we spawn with this build's id so a later app launch can tell whether the
|
||||
// bridge holding :14168 is ours (see bridge_identity_matches / GET /services/identity).
|
||||
cmd.env("GA_BUILD_ID", env!("GA_BUILD_ID"));
|
||||
}
|
||||
|
||||
/// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels.
|
||||
/// Streams the script's stdout and forwards GAPROGRESS markers to `report(pct, message)`.
|
||||
/// Blocking; intended to run on a background thread. Writes ~/.ga_desktop_settings.json.
|
||||
fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<(), String> {
|
||||
let root = bundle_root().ok_or("cannot locate bundle root")?;
|
||||
let wheels = root.join("wheels");
|
||||
|
||||
#[cfg(windows)]
|
||||
let (script, py) = (
|
||||
root.join("install_windows.ps1"),
|
||||
root.join("python").join("python.exe"),
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
let (script, py) = (
|
||||
root.join("install_macos.sh"),
|
||||
root.join("python").join("bin").join("python3"),
|
||||
);
|
||||
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||
let (script, py) = (
|
||||
root.join("install_linux.sh"),
|
||||
root.join("python").join("bin").join("python3"),
|
||||
);
|
||||
|
||||
if !script.exists() || !py.exists() || !wheels.exists() {
|
||||
return Err(format!("prepare resources missing under {:?}", root));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
|
||||
.arg(&script)
|
||||
.arg("-PythonPath").arg(&py)
|
||||
.arg("-ProjectDir").arg(project_dir)
|
||||
.arg("-WheelDir").arg(&wheels)
|
||||
.arg("-ExtraPipPackages").arg("fastapi uvicorn websockets")
|
||||
// -NoVenv: install deps straight into the embedded python (no venv) so the
|
||||
// bundle is relocatable. See prepared_marker / find_python.
|
||||
.args(["-Mode", "PrepareOnly", "-SkipNpmInstall", "-NoVenv"]);
|
||||
c
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("bash");
|
||||
c.arg(&script)
|
||||
.arg("--python-path").arg(&py)
|
||||
.arg("--project-dir").arg(project_dir)
|
||||
.arg("--wheel-dir").arg(&wheels)
|
||||
.arg("--extra-packages").arg("fastapi uvicorn websockets")
|
||||
// --no-venv: install deps straight into the embedded python (no venv) so the
|
||||
// bundle is relocatable. See prepared_marker / find_python.
|
||||
.args(["--mode", "PrepareOnly", "--no-venv"]);
|
||||
c
|
||||
};
|
||||
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
let mut child = cmd.spawn().map_err(|e| format!("failed to launch prepare: {}", e))?;
|
||||
|
||||
// Forward the script's ASCII progress keys to the loading window, which localizes them
|
||||
// (window.gaProgress maps key -> zh/en by navigator.language).
|
||||
if let Some(out) = child.stdout.take() {
|
||||
for line in BufReader::new(out).lines().flatten() {
|
||||
if let Some(key) = line.trim().strip_prefix("GAPROGRESS|") {
|
||||
match key.trim() {
|
||||
"venv" => report(15, "venv"),
|
||||
"deps" => report(45, "deps"),
|
||||
"done" => report(90, "done"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().map_err(|e| format!("prepare wait failed: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err(format!("prepare exited with status {:?}", status.code()));
|
||||
}
|
||||
// Record success so later launches (and relocated copies) skip the prepare step.
|
||||
if let Some(marker) = prepared_marker() {
|
||||
let _ = std::fs::write(&marker, b"ok\n");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /services/identity from a running bridge; returns the parsed JSON (or None when the
|
||||
/// endpoint is absent — i.e. an older/foreign bridge).
|
||||
fn bridge_reported_identity() -> Option<serde_json::Value> {
|
||||
use std::io::{Read, Write};
|
||||
let mut stream = TcpStream::connect_timeout(
|
||||
&"127.0.0.1:14168".parse().unwrap(),
|
||||
Duration::from_millis(800),
|
||||
).ok()?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
|
||||
let req = b"GET /services/identity HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(req).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
let _ = stream.read_to_end(&mut buf);
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let body = text.split("\r\n\r\n").nth(1)?;
|
||||
serde_json::from_str(body.trim()).ok()
|
||||
}
|
||||
|
||||
fn norm_path(p: &str) -> String {
|
||||
std::fs::canonicalize(p)
|
||||
.map(|c| c.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| p.to_string())
|
||||
}
|
||||
|
||||
/// A running bridge is "ours" only when it serves the same install path AND was spawned by the
|
||||
/// same build. The build id (commit+timestamp, see build.rs) changes on every build, so an
|
||||
/// in-place upgrade or a same-version re-publish still counts as a different bridge → take over.
|
||||
/// An old bridge with no /identity (None) or no build_id field ("") never matches → taken over.
|
||||
fn bridge_identity_matches(project_dir: &str) -> bool {
|
||||
let Some(id) = bridge_reported_identity() else { return false; };
|
||||
let reported_root = id.get("ga_root").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let reported_build = id.get("build_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if reported_build != env!("GA_BUILD_ID") {
|
||||
return false;
|
||||
}
|
||||
let (a, b) = (norm_path(reported_root), norm_path(project_dir));
|
||||
#[cfg(windows)]
|
||||
{ a.eq_ignore_ascii_case(&b) }
|
||||
#[cfg(not(windows))]
|
||||
{ a == b }
|
||||
}
|
||||
|
||||
/// Last resort when a stale bridge ignores POST /services/bridge/exit (e.g. an old build with
|
||||
/// no such endpoint): force-kill whatever process is listening on :14168 so the new bridge can
|
||||
/// bind it. Only called after an identity mismatch, so we never kill a bridge that is ours.
|
||||
fn force_free_bridge_port() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// netstat -ano: last column is the PID for the :14168 LISTENING row.
|
||||
if let Ok(out) = Command::new("netstat").args(["-ano", "-p", "tcp"]).output() {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
for line in text.lines() {
|
||||
if line.contains(":14168") && line.to_uppercase().contains("LISTENING") {
|
||||
if let Some(pid) = line.split_whitespace().last() {
|
||||
let mut c = Command::new("taskkill");
|
||||
c.args(["/F", "/PID", pid]);
|
||||
c.creation_flags(0x08000000);
|
||||
let _ = c.status();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// lsof prints the listening PIDs; kill -9 each.
|
||||
if let Ok(out) = Command::new("lsof").args(["-ti", "tcp:14168", "-sTCP:LISTEN"]).output() {
|
||||
for pid in String::from_utf8_lossy(&out.stdout).split_whitespace() {
|
||||
let _ = Command::new("kill").args(["-9", pid]).status();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_bridge_shutdown() {
|
||||
use std::io::{Read, Write};
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(
|
||||
&"127.0.0.1:14168".parse().unwrap(),
|
||||
Duration::from_millis(800),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
|
||||
let req = b"POST /services/bridge/exit HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
let _ = stream.write_all(req);
|
||||
let _ = stream.read(&mut [0u8; 512]);
|
||||
}
|
||||
|
||||
fn takeover_stale_bridge(project_dir: &str) {
|
||||
if project_dir.is_empty() || !is_bridge_running() {
|
||||
return;
|
||||
}
|
||||
if bridge_identity_matches(project_dir) {
|
||||
return;
|
||||
}
|
||||
eprintln!("[tauri] a different/stale bridge holds 127.0.0.1:14168; taking over");
|
||||
request_bridge_shutdown();
|
||||
let start = Instant::now();
|
||||
while is_bridge_running() && start.elapsed() < Duration::from_secs(10) {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
// Old bridges have no /services/bridge/exit endpoint and ignore the request above — if the
|
||||
// port is still held, force-kill the listener so our fresh bridge can bind it.
|
||||
if is_bridge_running() {
|
||||
eprintln!("[tauri] stale bridge did not exit; force-freeing :14168");
|
||||
force_free_bridge_port();
|
||||
let start = Instant::now();
|
||||
while is_bridge_running() && start.elapsed() < Duration::from_secs(5) {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bridge_running() -> bool {
|
||||
TcpStream::connect(("127.0.0.1", 14168)).is_ok()
|
||||
}
|
||||
|
||||
fn wait_for_port(port: u16, timeout: Duration) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn spawn_bridge_process(python_path: &str, project_dir: &str) -> Result<(), String> {
|
||||
if is_bridge_running() {
|
||||
return Ok(());
|
||||
}
|
||||
let py = PathBuf::from(python_path);
|
||||
let dir = PathBuf::from(project_dir);
|
||||
let script = dir.join("frontends").join("desktop_bridge.py");
|
||||
if !script.exists() {
|
||||
return Err(format!("desktop_bridge.py not found at {:?}", script));
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&py);
|
||||
cmd.arg(&script).current_dir(&dir);
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?;
|
||||
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_bridge_window(app_handle: &tauri::AppHandle) {
|
||||
if let Some(main_win) = app_handle.get_webview_window("main") {
|
||||
let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap();
|
||||
let _ = main_win.navigate(url);
|
||||
let _ = main_win.show();
|
||||
let _ = main_win.set_focus();
|
||||
}
|
||||
if let Some(setup_win) = app_handle.get_webview_window("setup") {
|
||||
let _ = setup_win.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, project_dir: String) -> Result<(), String> {
|
||||
// Save to settings (merge so sibling keys like desktop_shortcut survive).
|
||||
merge_settings(serde_json::json!({"python_path": python_path, "project_dir": project_dir}));
|
||||
|
||||
spawn_bridge_process(&python_path, &project_dir)?;
|
||||
|
||||
// Wait for port
|
||||
if !wait_for_port(14168, Duration::from_secs(20)) {
|
||||
return Err("Bridge did not become ready within 20s".into());
|
||||
}
|
||||
|
||||
show_bridge_window(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_bridge(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let (python_path, project_dir) = get_or_discover_config();
|
||||
spawn_bridge_process(&python_path, &project_dir)?;
|
||||
if !wait_for_port(14168, Duration::from_secs(20)) {
|
||||
return Err("Bridge did not become ready within 20s".into());
|
||||
}
|
||||
show_bridge_window(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config() -> (String, String) {
|
||||
get_or_discover_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_mykey(content: String) -> Result<Option<String>, String> {
|
||||
let path = rfd::FileDialog::new()
|
||||
.set_file_name("mykey.py")
|
||||
.add_filter("Python", &["py"])
|
||||
.save_file();
|
||||
match path {
|
||||
Some(p) => {
|
||||
std::fs::write(&p, content.as_bytes()).map_err(|e| e.to_string())?;
|
||||
Ok(Some(p.to_string_lossy().into_owned()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let no_autostart = args.iter().any(|a| a == "--no-autostart");
|
||||
let dev_mode = args.iter().any(|a| a == "--dev");
|
||||
|
||||
let project_dir = find_project_dir().unwrap_or_default();
|
||||
let needs_prepare = needs_first_run_prepare(&project_dir);
|
||||
|
||||
takeover_stale_bridge(&project_dir);
|
||||
|
||||
let bridge_ok = is_bridge_running();
|
||||
let mut spawned_bridge = false;
|
||||
// Skip the early spawn when a first-run prepare is required (no venv yet);
|
||||
// the setup thread prepares the env first and then starts the bridge.
|
||||
if !bridge_ok && !no_autostart && !needs_prepare {
|
||||
// Try to start bridge with saved/discovered config
|
||||
let (py_str, dir_str) = get_or_discover_config();
|
||||
let dir = PathBuf::from(&dir_str);
|
||||
let script = dir.join("frontends").join("desktop_bridge.py");
|
||||
if script.exists() {
|
||||
let mut cmd = Command::new(&py_str);
|
||||
cmd.arg(&script).current_dir(&dir);
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000);
|
||||
if let Ok(child) = cmd.spawn() {
|
||||
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
|
||||
spawned_bridge = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide])
|
||||
.setup(move |app| {
|
||||
// Show the loading window immediately so the first-run prepare isn't a blank screen.
|
||||
// The window starts on loading.html (a local page), so no "connection refused" flash.
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
}
|
||||
|
||||
let handle = app.handle().clone();
|
||||
let project_dir = project_dir.clone();
|
||||
thread::spawn(move || {
|
||||
// Progress reporter: push status into the loading window (window.gaProgress).
|
||||
let main_win = handle.get_webview_window("main");
|
||||
|
||||
let report = |pct: i32, msg: &str| {
|
||||
if let Some(w) = &main_win {
|
||||
let js = format!(
|
||||
"window.gaProgress && window.gaProgress({}, {})",
|
||||
pct,
|
||||
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
|
||||
);
|
||||
let _ = w.eval(&js);
|
||||
}
|
||||
};
|
||||
|
||||
// First-run (self-contained bundle): prepare the embedded python env offline,
|
||||
// then start the bridge with the freshly created venv.
|
||||
if needs_prepare {
|
||||
report(5, "start");
|
||||
if let Err(e) = run_offline_prepare(&project_dir, &report) {
|
||||
eprintln!("[tauri] first-run prepare failed: {}", e);
|
||||
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.show(); }
|
||||
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
|
||||
return;
|
||||
}
|
||||
report(95, "starting");
|
||||
if !is_bridge_running() {
|
||||
let (py_str, dir_str) = get_or_discover_config();
|
||||
let dir = PathBuf::from(&dir_str);
|
||||
let script = dir.join("frontends").join("desktop_bridge.py");
|
||||
if script.exists() {
|
||||
let mut cmd = Command::new(&py_str);
|
||||
cmd.arg(&script).current_dir(&dir);
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000);
|
||||
if let Ok(child) = cmd.spawn() {
|
||||
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First run (prepare) and cold bridge start may take a while; allow up to 60s.
|
||||
let wait = if needs_prepare || spawned_bridge {
|
||||
Duration::from_secs(60)
|
||||
} else {
|
||||
Duration::from_secs(2)
|
||||
};
|
||||
let bridge_ready = wait_for_port(14168, wait);
|
||||
|
||||
if bridge_ready {
|
||||
// The bridge auto-starts conductor + scheduler itself (on_startup), so we do
|
||||
// NOT probe their ports here: that would self-detect the bridge's own
|
||||
// just-started extras and falsely report "ports busy".
|
||||
if !wait_for_port(14168, Duration::from_secs(15)) {
|
||||
eprintln!("[tauri] bridge not reachable before navigate");
|
||||
if let Some(w) = &main_win {
|
||||
let msg = "无法连接 bridge (127.0.0.1:14168),请关闭程序后重试。";
|
||||
let js = format!(
|
||||
"alert({})",
|
||||
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
|
||||
);
|
||||
let _ = w.eval(&js);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Navigate to the bridge HTTP only after it is ready.
|
||||
if let Some(w) = handle.get_webview_window("main") {
|
||||
if let Ok(url) = tauri::Url::parse("http://127.0.0.1:14168/") {
|
||||
let _ = w.navigate(url);
|
||||
}
|
||||
if dev_mode {
|
||||
w.open_devtools();
|
||||
} else {
|
||||
// Disable F5/F12/Ctrl+R/right-click in production
|
||||
let _ = w.eval(r#"
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'F12' || e.key === 'F5' ||
|
||||
(e.ctrlKey && e.key === 'r') ||
|
||||
(e.ctrlKey && e.shiftKey && e.key === 'I')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
document.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
"#);
|
||||
}
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.hide(); }
|
||||
// App is up and reachable: ask-once / self-heal the desktop shortcut.
|
||||
// Runs last so it never blocks the loading/navigation path.
|
||||
maybe_setup_shortcut();
|
||||
} else {
|
||||
// Bridge never came up -> let the user fix paths in the setup window.
|
||||
if let Some(sw) = handle.get_webview_window("setup") {
|
||||
if dev_mode { sw.open_devtools(); }
|
||||
let _ = sw.show();
|
||||
}
|
||||
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
let label = window.label();
|
||||
if label == "main" {
|
||||
// Persistent backend: closing the window does NOT stop the bridge or its
|
||||
// services, so relaunching attaches to the warm backend on 14168.
|
||||
window.app_handle().exit(0);
|
||||
} else if label == "setup" {
|
||||
// Setup closed -> exit if main is not visible
|
||||
if let Some(main_win) = window.app_handle().get_webview_window("main") {
|
||||
if !main_win.is_visible().unwrap_or(false) {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ga_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"productName": "GenericAgent",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.genericagent.app",
|
||||
"build": {
|
||||
"frontendDist": "../static"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "GenericAgent",
|
||||
"label": "main",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"maximized": false,
|
||||
"visible": false,
|
||||
"url": "loading.html"
|
||||
},
|
||||
{
|
||||
"title": "GenericAgent — Setup",
|
||||
"label": "setup",
|
||||
"width": 600,
|
||||
"height": 580,
|
||||
"resizable": false,
|
||||
"visible": false,
|
||||
"center": true,
|
||||
"url": "fallback.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "dmg"],
|
||||
"icon": ["icons/icon.ico", "icons/icon.png", "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png"]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
# Desktop Font Assets
|
||||
|
||||
These font files are the offline Latin bundle for the vanilla desktop shell.
|
||||
|
||||
## Files
|
||||
|
||||
- `azonix-wordmark.woff2`
|
||||
- Role: brand wordmark only
|
||||
- Source: converted on 2026-05-25 from the local owner-installed file at `~/Library/Fonts/azonix/Azonix.otf`
|
||||
- License: owner-provided / verify before external redistribution
|
||||
- Note: this repo currently treats Azonix as a project-local brand asset
|
||||
|
||||
- `lexend-latin.woff2`
|
||||
- Role: English titles and navigation
|
||||
- Source: Google Fonts CSS2 API, specimen page <https://fonts.google.com/specimen/Lexend>
|
||||
- Downloaded: 2026-05-25
|
||||
- License: SIL Open Font License 1.1
|
||||
|
||||
- `noto-sans-latin.woff2`
|
||||
- Role: English body copy and default Latin UI text
|
||||
- Source: Google Fonts CSS2 API, specimen page <https://fonts.google.com/noto/specimen/Noto+Sans>
|
||||
- Downloaded: 2026-05-25
|
||||
- License: SIL Open Font License 1.1
|
||||
|
||||
- `jetbrains-mono-latin.woff2`
|
||||
- Role: config/value dense controls and numeric surfaces
|
||||
- Source: Google Fonts CSS2 API, specimen page <https://fonts.google.com/specimen/JetBrains+Mono>
|
||||
- Downloaded: 2026-05-25
|
||||
- License: SIL Open Font License 1.1
|
||||
|
||||
## Notes
|
||||
|
||||
- Only Latin subsets are bundled here. Chinese UI text continues to use the existing system fallback stack.
|
||||
- Runtime does not fetch fonts from a CDN. `frontends/desktop/static/assets/fonts/fonts.css` is the only font entrypoint for the vanilla shell.
|
||||
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
@font-face {
|
||||
font-family: "GA Azonix";
|
||||
src:
|
||||
url("./azonix-wordmark.woff2") format("woff2"),
|
||||
local("Azonix"),
|
||||
local("Azonix Regular"),
|
||||
local("Azonix-Regular");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "GA JetBrains Mono";
|
||||
src: url("./jetbrains-mono-latin.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>GenericAgent — Setup</title>
|
||||
<style>
|
||||
:root{--bg:#fff;--text:#0a0a0a;--muted:#6b6b6b;--border:#e8e8e8;--accent:#0a0a0a;--accent-text:#fff;--radius:10px;--font:-apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue",Helvetica,Arial,sans-serif}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--text:#ededed;--muted:#a0a0a0;--border:#2a2a2a;--accent:#ededed;--accent-text:#0a0a0a}}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;font-family:var(--font);background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center}
|
||||
.card{max-width:480px;width:90%;padding:2.5rem;border:1px solid var(--border);border-radius:var(--radius);text-align:center}
|
||||
h1{font-size:1.4rem;margin:0 0 .5rem}
|
||||
p{color:var(--muted);font-size:.9rem;margin:.4rem 0;line-height:1.5}
|
||||
.input-row{display:flex;gap:8px;margin-top:1.2rem}
|
||||
input[type=text]{flex:1;padding:10px 12px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;background:var(--bg);color:var(--text);outline:none}
|
||||
input[type=text]:focus{border-color:var(--accent)}
|
||||
button{padding:10px 18px;border:none;border-radius:6px;font-size:.9rem;cursor:pointer;background:var(--accent);color:var(--accent-text);font-weight:500}
|
||||
button:hover{opacity:.85}
|
||||
button:disabled{opacity:.4;cursor:not-allowed}
|
||||
.field-label{display:block;text-align:left;font-size:.8rem;font-weight:500;margin-top:1rem;margin-bottom:.3rem;color:var(--text)}
|
||||
#status{margin-top:1rem;font-size:.85rem;min-height:1.2em}
|
||||
.err{color:#dc2626}.ok{color:#16a34a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>⚙️ Setup Required</h1>
|
||||
<p>The backend could not start. Please configure the paths below.</p>
|
||||
<p>后端启动失败,请配置以下路径。</p>
|
||||
|
||||
<label class="field-label">Python interpreter 解释器路径</label>
|
||||
<div class="input-row">
|
||||
<input id="pypath" type="text" placeholder="python / path to interpreter" spellcheck="false">
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:.75rem;margin-top:.3rem">
|
||||
e.g. <code>C:/Python312/python.exe</code> / <code>~/miniconda3/envs/myenv/bin/python</code>
|
||||
</p>
|
||||
|
||||
<label class="field-label">Project directory 项目目录</label>
|
||||
<div class="input-row">
|
||||
<input id="projdir" type="text" placeholder="path to GenericAgent folder" spellcheck="false">
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:.75rem;margin-top:.3rem">
|
||||
The folder containing <code>frontends/desktop_bridge.py</code>
|
||||
</p>
|
||||
|
||||
<button id="start-btn" onclick="doStart()" style="margin-top:1.2rem;width:100%">Start 启动</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
<script>
|
||||
const {invoke} = window.__TAURI__.core;
|
||||
const {getCurrentWindow, Window} = window.__TAURI__.window;
|
||||
const statusEl = document.getElementById('status');
|
||||
const btn = document.getElementById('start-btn');
|
||||
const pyInput = document.getElementById('pypath');
|
||||
const dirInput = document.getElementById('projdir');
|
||||
|
||||
// Pre-fill from Rust config discovery
|
||||
invoke('get_config').then(([py, dir]) => {
|
||||
if (py) pyInput.value = py;
|
||||
if (dir) dirInput.value = dir;
|
||||
}).catch(()=>{});
|
||||
|
||||
async function doStart() {
|
||||
const pythonPath = pyInput.value.trim();
|
||||
const projectDir = dirInput.value.trim();
|
||||
if (!pythonPath) { statusEl.className='err'; statusEl.textContent='Please enter Python path / 请输入 Python 路径'; return; }
|
||||
if (!projectDir) { statusEl.className='err'; statusEl.textContent='Please enter project directory / 请输入项目目录'; return; }
|
||||
btn.disabled = true;
|
||||
statusEl.className=''; statusEl.textContent='Starting bridge… 正在启动…';
|
||||
try {
|
||||
await invoke('start_bridge_with_config', {pythonPath, projectDir});
|
||||
statusEl.className='ok'; statusEl.textContent='Connected! 已连接,正在打开主窗口…';
|
||||
// Show main window and hide setup
|
||||
const mainWin = await Window.getByLabel('main');
|
||||
if (mainWin) {
|
||||
await mainWin.show();
|
||||
await mainWin.setFocus();
|
||||
}
|
||||
const setupWin = getCurrentWindow();
|
||||
await setupWin.hide();
|
||||
} catch(e) {
|
||||
statusEl.className='err'; statusEl.textContent='Failed: '+e;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
pyInput.addEventListener('keydown', e=>{ if(e.key==='Enter') dirInput.focus(); });
|
||||
dirInput.addEventListener('keydown', e=>{ if(e.key==='Enter') doStart(); });
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Input length limit (300 chars) — in separate script to work even without Tauri
|
||||
const MAX_LEN = 300;
|
||||
const _pyInput = document.getElementById('pypath');
|
||||
const _dirInput = document.getElementById('projdir');
|
||||
[_pyInput, _dirInput].forEach(el => {
|
||||
if (!el) return;
|
||||
const hint = document.createElement('span');
|
||||
hint.style.cssText = 'color:#dc2626;font-size:.75rem;display:none;margin-top:2px';
|
||||
el.parentNode.appendChild(hint);
|
||||
|
||||
function checkLimit() {
|
||||
if (el.value.length > MAX_LEN) {
|
||||
el.value = el.value.slice(0, MAX_LEN);
|
||||
}
|
||||
if (el.value.length >= MAX_LEN) {
|
||||
hint.textContent = 'Character limit reached (' + MAX_LEN + ') / 已达字数上限(' + MAX_LEN + ')';
|
||||
hint.style.display = 'block';
|
||||
} else {
|
||||
hint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept paste: only take first MAX_LEN chars from clipboard
|
||||
el.addEventListener('paste', e => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text') || '';
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const remaining = MAX_LEN - el.value.length + (end - start);
|
||||
const insert = text.slice(0, Math.max(0, remaining));
|
||||
el.value = el.value.slice(0, start) + insert + el.value.slice(end);
|
||||
el.setSelectionRange(start + insert.length, start + insert.length);
|
||||
checkLimit();
|
||||
});
|
||||
|
||||
// Fallback: keyboard input beyond limit
|
||||
el.addEventListener('input', checkLimit);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
// GenericAgent Web2 browser bridge adapter.
|
||||
// HTTP is the command/data channel. WebSocket only carries small state events.
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const listeners = new Map();
|
||||
let ws = null;
|
||||
let cachedBridgeReady = null;
|
||||
const bridgeBase = `${location.protocol}//${location.hostname}:14168`;
|
||||
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname}:14168/ws`;
|
||||
|
||||
function on(channel, cb) {
|
||||
if (typeof cb !== 'function') return () => {};
|
||||
if (!listeners.has(channel)) listeners.set(channel, new Set());
|
||||
listeners.get(channel).add(cb);
|
||||
if (channel === 'bridge-ready' && cachedBridgeReady) {
|
||||
try { cb(cachedBridgeReady); } catch (err) { console.error('[ga-web2 listener] replay bridge-ready', err); }
|
||||
}
|
||||
return () => listeners.get(channel)?.delete(cb);
|
||||
}
|
||||
|
||||
function emit(channel, payload) {
|
||||
if (channel === 'bridge-ready') cachedBridgeReady = payload;
|
||||
const set = listeners.get(channel);
|
||||
if (!set) return;
|
||||
for (const cb of Array.from(set)) {
|
||||
try { cb(payload); } catch (err) { console.error('[ga-web2 listener]', channel, err); }
|
||||
}
|
||||
}
|
||||
|
||||
async function http(path, options = {}) {
|
||||
const headers = Object.assign({}, options.headers || {});
|
||||
const init = Object.assign({}, options, { headers });
|
||||
if (init.body && typeof init.body !== 'string') {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
init.body = JSON.stringify(init.body);
|
||||
}
|
||||
const res = await fetch(`${bridgeBase}${path}`, init);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function connectWs() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.addEventListener('open', () => emit('bridge-log', 'WS state channel connected'));
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (_) { return; }
|
||||
if (msg.type === 'bridge-ready') {
|
||||
emit('bridge-ready', msg);
|
||||
} else if (msg.type === 'session-state') {
|
||||
emit('bridge-notification', msg);
|
||||
} else if (msg.type === 'bridge-log') {
|
||||
emit('bridge-log', msg.payload || msg);
|
||||
} else if (msg.type === 'bridge-error') {
|
||||
emit('bridge-error', msg.payload || msg);
|
||||
}
|
||||
});
|
||||
ws.addEventListener('close', () => emit('bridge-closed', { reason: 'ws-closed' }));
|
||||
ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket state channel error' }));
|
||||
} catch (err) {
|
||||
emit('bridge-error', { type: 'ws-error', message: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function rpc(method, params = {}) {
|
||||
switch (method) {
|
||||
case 'app/status':
|
||||
return http('/status');
|
||||
case 'app/config/get':
|
||||
return http('/config');
|
||||
case 'app/config/save':
|
||||
return http('/config', { method: 'POST', body: params || {} });
|
||||
case 'get/model-profiles':
|
||||
return http('/model-profiles');
|
||||
case 'session/new':
|
||||
return http('/session/new', { method: 'POST', body: params || {} });
|
||||
case 'session/prompt': {
|
||||
const sid = params.sessionId || params.id || params.bridgeSessionId;
|
||||
if (!sid) throw new Error('session/prompt missing sessionId');
|
||||
return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} });
|
||||
}
|
||||
case 'session/poll': {
|
||||
const sid = params.sessionId || params.id || params.bridgeSessionId;
|
||||
if (!sid) throw new Error('session/poll missing sessionId');
|
||||
const after = params.afterId ?? params.after ?? 0;
|
||||
const limit = params.limit ?? 200;
|
||||
return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
case 'session/cancel': {
|
||||
const sid = params.sessionId || params.id || params.bridgeSessionId;
|
||||
if (!sid) throw new Error('session/cancel missing sessionId');
|
||||
return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} });
|
||||
}
|
||||
case 'app/path/open':
|
||||
return http('/path/open', { method: 'POST', body: params || {} });
|
||||
case 'app/path/selectGaRoot':
|
||||
return http('/config');
|
||||
case 'list_continuable_sessions':
|
||||
return { sessions: [] };
|
||||
case 'restore_session':
|
||||
throw new Error('restore_session is not implemented in web2 bridge');
|
||||
default:
|
||||
throw new Error(`Unknown RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.ga = {
|
||||
platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
|
||||
startBridge: async () => { connectWs(); return http('/status'); },
|
||||
stopBridge: async () => ({ ok: true }),
|
||||
checkStatus: () => rpc('app/status', {}),
|
||||
getConfig: () => rpc('app/config/get', {}),
|
||||
saveConfig: (cfg) => rpc('app/config/save', cfg || {}),
|
||||
getModelProfiles: () => rpc('get/model-profiles', {}),
|
||||
selectGaRoot: () => rpc('app/path/selectGaRoot', {}),
|
||||
openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }),
|
||||
openMykey: () => rpc('app/path/open', { kind: 'mykey' }),
|
||||
pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }),
|
||||
rpc,
|
||||
onBridgeMessage: (cb) => on('bridge-message', cb),
|
||||
onBridgeNotification: (cb) => on('bridge-notification', cb),
|
||||
onBridgeError: (cb) => on('bridge-error', cb),
|
||||
onBridgeClosed: (cb) => on('bridge-closed', cb),
|
||||
onBridgeReady: (cb) => on('bridge-ready', cb),
|
||||
onBridgeLog: (cb) => on('bridge-log', cb),
|
||||
onOpenSearch: (cb) => on('open-search', cb)
|
||||
};
|
||||
|
||||
connectWs();
|
||||
http('/status').then(status => emit('bridge-ready', status)).catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) }));
|
||||
})();
|
||||
@@ -0,0 +1,679 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>GenericAgent</title>
|
||||
<link rel="stylesheet" href="assets/fonts/fonts.css?v=2">
|
||||
<link rel="stylesheet" href="styles.css?v=181">
|
||||
<!-- KaTeX: LaTeX 公式渲染 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
|
||||
<!-- highlight.js: 代码高亮 -->
|
||||
<link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" crossorigin="anonymous">
|
||||
<!-- Flatpickr: 日期选择器 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('ga_theme'); if(t) document.documentElement.dataset.theme=t;
|
||||
var l=localStorage.getItem('ga_lang'); if(l==='en') document.documentElement.lang='en';
|
||||
var legacy=localStorage.getItem('ga_style');
|
||||
var app=localStorage.getItem('ga_appearance');
|
||||
if(!app&&legacy){
|
||||
if(legacy==='dark') app='dark';
|
||||
else{ app='light'; if(legacy==='classic') localStorage.setItem('ga_plain','1'); }
|
||||
localStorage.setItem('ga_appearance',app);
|
||||
localStorage.removeItem('ga_style');
|
||||
}
|
||||
app=app||'light';
|
||||
document.documentElement.dataset.appearance=app;
|
||||
if(app==='light'&&localStorage.getItem('ga_plain')==='1') document.documentElement.dataset.plain='1';
|
||||
var LEGACY_FONT={sm:12,md:14,lg:16};
|
||||
function chatFontPx(v){
|
||||
if(v&&LEGACY_FONT[v]) return LEGACY_FONT[v];
|
||||
var n=parseInt(v,10); if(!isNaN(n)&&n>=10&&n<=20) return n;
|
||||
return 14;
|
||||
}
|
||||
var fs=localStorage.getItem('ga_font_size');
|
||||
var cfp=chatFontPx(fs);
|
||||
document.documentElement.dataset.chatFont=String(cfp);
|
||||
document.documentElement.style.setProperty('--chat-font',cfp+'px');
|
||||
var hljs=document.getElementById('hljs-theme');
|
||||
if(hljs&&app==='dark') hljs.href='https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css';
|
||||
try{ var sraw=localStorage.getItem('ga_sessions'); if(sraw && JSON.parse(sraw).length) document.documentElement.dataset.bootHasSessions='1'; }catch(_){}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
|
||||
<div class="body">
|
||||
|
||||
<!-- ① 左侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<div class="brand-name ga-wordmark">Generic Agent</div>
|
||||
<div class="brand-sub" data-i18n="brand.sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav" id="nav">
|
||||
<a class="nav-item active" data-page="chat">
|
||||
<span class="ic" data-ga-icon="chatTeardropText"></span>
|
||||
<span data-i18n="nav.chat"></span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="collab">
|
||||
<span class="ic" data-ga-icon="gitFork"></span>
|
||||
<span data-i18n="nav.collab"></span>
|
||||
<span id="collab-badge" class="collab-nav-badge" hidden></span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="services">
|
||||
<span class="ic" data-ga-icon="broadcast"></span>
|
||||
<span data-i18n="nav.services"></span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="token">
|
||||
<span class="ic" data-ga-icon="coins"></span>
|
||||
<span data-i18n="nav.token"></span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 快速接入官方模型(DeepSeek / 通义千问):填好 API Key 即可使用 -->
|
||||
<div class="provider-quickstart" id="provider-quickstart">
|
||||
<div class="pq-head" id="pq-toggle" role="button" tabindex="0" aria-expanded="true">
|
||||
<span class="fr-l">
|
||||
<span class="ic" data-ga-icon="lightning"></span>
|
||||
<span class="pq-title" data-i18n="pq.title"></span>
|
||||
</span>
|
||||
<span class="pq-collapse chev" data-ga-icon="caretRight" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="pq-body">
|
||||
<button type="button" class="pq-btn" data-provider="deepseek">
|
||||
<span class="pq-logo" style="background:rgba(77,107,254,.12)" aria-hidden="true"><svg viewBox="0 0 24 24" fill="#4D6BFE" xmlns="http://www.w3.org/2000/svg"><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></svg></span>
|
||||
<span class="pq-btn-text">
|
||||
<span class="pq-btn-name">DeepSeek</span>
|
||||
<span class="pq-btn-desc" data-i18n="pq.deepseekDesc"></span>
|
||||
</span>
|
||||
<span class="pq-arrow" data-ga-icon="caretRight"></span>
|
||||
</button>
|
||||
<button type="button" class="pq-btn" data-provider="qwen">
|
||||
<span class="pq-logo" style="background:rgba(97,92,237,.12)" aria-hidden="true"><svg viewBox="0 0 24 24" fill="#615CED" xmlns="http://www.w3.org/2000/svg"><path d="M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z"/></svg></span>
|
||||
<span class="pq-btn-text">
|
||||
<span class="pq-btn-name">通义千问</span>
|
||||
<span class="pq-btn-desc" data-i18n="pq.qwenDesc"></span>
|
||||
</span>
|
||||
<span class="pq-arrow" data-ga-icon="caretRight"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<a class="foot-row" id="settings-btn">
|
||||
<span class="fr-l">
|
||||
<span class="ic" data-ga-icon="gear"></span>
|
||||
<span data-i18n="foot.settings"></span>
|
||||
</span>
|
||||
<span class="chev" data-ga-icon="caretRight"></span>
|
||||
</a>
|
||||
<div class="foot-ver" data-i18n="foot.ver"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="sb-resize" id="sb-resize"></div>
|
||||
|
||||
<!-- 中间主区 -->
|
||||
<main class="main">
|
||||
|
||||
<div id="pages">
|
||||
|
||||
<!-- 聊天 -->
|
||||
<section class="page active page--chat-ui" data-page="chat">
|
||||
<div class="page-top">
|
||||
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
|
||||
<span data-ga-icon="caretLeft"></span>
|
||||
</button>
|
||||
<button class="run-state" id="run-toggle" type="button">
|
||||
<i class="dot"></i> <span class="rs-label"></span>
|
||||
</button>
|
||||
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
|
||||
<span data-ga-icon="caretRight"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-shell">
|
||||
<div class="chat-panel">
|
||||
<div class="msg-area">
|
||||
<div class="chat-start">
|
||||
<div class="feature-head">
|
||||
<div class="page-title" data-i18n="chat.startTitle"></div>
|
||||
<div class="page-sub" data-i18n="chat.startSub"></div>
|
||||
</div>
|
||||
<div class="feature-grid"></div>
|
||||
</div>
|
||||
<div class="session-loading" id="session-loading" hidden aria-live="polite">
|
||||
<span class="ga-spin" aria-hidden="true"></span>
|
||||
<span data-i18n="chat.sessionLoading"></span>
|
||||
</div>
|
||||
<div class="msg-loading" id="msg-loading" hidden aria-live="polite">
|
||||
<span class="ga-spin" aria-hidden="true"></span>
|
||||
<span data-i18n="chat.interrupting"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-anchor" id="chat-composer-anchor">
|
||||
<div class="composer" id="chat-composer" data-composer-ctx="chat">
|
||||
<div class="composer-slot">
|
||||
<div class="plan-card" id="plan-bar" hidden aria-live="polite"></div>
|
||||
<div class="composer-inset">
|
||||
<div class="thumb-strip" id="thumb-strip" hidden></div>
|
||||
<div class="input" id="chat-input" contenteditable="true" role="textbox" aria-multiline="true" data-i18n-ph="composer.placeholder"></div>
|
||||
<div class="composer-bot">
|
||||
<div class="composer-plus-wrap">
|
||||
<button type="button" class="ic-btn composer-plus" id="chat-plus-btn" aria-haspopup="menu" aria-expanded="false" data-i18n-title="collab.plusMenu" title="">
|
||||
<span data-ga-icon="plus"></span>
|
||||
</button>
|
||||
<div class="ga-menu composer-menu" id="chat-menu" hidden role="menu">
|
||||
<button type="button" class="ga-menu-item" data-composer-action="upload" role="menuitem">
|
||||
<span data-ga-icon="paperclip"></span>
|
||||
<span data-i18n="upload.button"></span>
|
||||
</button>
|
||||
<button type="button" class="ga-menu-item" data-composer-action="preset" role="menuitem">
|
||||
<span data-ga-icon="lightning"></span>
|
||||
<span data-i18n="modal.preset"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="chip" id="model-chip" data-i18n-title="model.menuLabel" title=""><span class="model-name"></span> <span class="chip-ic" data-ga-icon="caretDown"></span></button>
|
||||
<button type="button" class="send" id="send-btn" aria-label="send">
|
||||
<span data-ga-icon="paperPlaneTilt"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ga-menu" id="model-menu" hidden></div>
|
||||
<input type="file" id="chat-file-input" multiple hidden>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 后台服务:消息通道 + 状态面板 -->
|
||||
<section class="page" data-page="services">
|
||||
<div class="page-top">
|
||||
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
|
||||
<span data-ga-icon="caretLeft"></span>
|
||||
</button>
|
||||
<span class="pt-spacer"></span>
|
||||
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
|
||||
<span data-ga-icon="caretRight"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="page-pad">
|
||||
<div class="page-title" data-i18n="page.services.title"></div>
|
||||
<div class="svc-tabs" id="svc-tabs">
|
||||
<button type="button" class="svc-tab active" data-tab="channels" data-i18n="page.channels.title"></button>
|
||||
<button type="button" class="svc-tab" data-tab="status" data-i18n="page.status.title"></button>
|
||||
</div>
|
||||
<div class="svc-panel active" data-svc-panel="channels">
|
||||
<div class="list" id="chan-list"></div>
|
||||
<div class="list-empty" id="chan-empty" hidden data-i18n="ch.empty"></div>
|
||||
</div>
|
||||
<div class="svc-panel" data-svc-panel="status">
|
||||
<div class="list" id="status-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 指挥家 Conductor(对话区结构复用 page--chat-ui / chat-shell) -->
|
||||
<section class="page page--chat-ui" data-page="collab">
|
||||
<div class="page-top">
|
||||
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
|
||||
<span data-ga-icon="caretLeft"></span>
|
||||
</button>
|
||||
<div class="page-top-center">
|
||||
<button class="run-state" id="collab-run-toggle" type="button">
|
||||
<i class="dot"></i> <span class="rs-label"></span>
|
||||
</button>
|
||||
<button type="button" class="pt-icon collab-retry-btn" id="collab-retry" hidden data-i18n-aria-label="collab.retry" aria-label="retry">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
|
||||
<span data-ga-icon="caretRight"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-shell">
|
||||
<div class="chat-panel">
|
||||
<div class="msg-area collab-msgs" id="collab-msgs">
|
||||
<div class="collab-scroll">
|
||||
<div class="collab-guide" id="collab-welcome" hidden>
|
||||
<div class="feature-head">
|
||||
<div class="page-title" data-i18n="collab.guideTitle"></div>
|
||||
<p class="page-sub" data-i18n="collab.guideWhen"></p>
|
||||
</div>
|
||||
<div class="collab-guide-steps">
|
||||
<div class="collab-guide-step">
|
||||
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="chatTeardropText"></span></span>
|
||||
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep1t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep1d"></span></span>
|
||||
</div>
|
||||
<div class="collab-guide-step">
|
||||
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="gridFour"></span></span>
|
||||
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep2t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep2d"></span></span>
|
||||
</div>
|
||||
<div class="collab-guide-step">
|
||||
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="listChecks"></span></span>
|
||||
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep3t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep3d"></span></span>
|
||||
</div>
|
||||
<div class="collab-guide-step">
|
||||
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="pencilSimple"></span></span>
|
||||
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep4t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep4d"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msgs" id="collab-msg-list" hidden></div>
|
||||
</div>
|
||||
<div class="collab-overlay">
|
||||
<div class="collab-rail" id="collab-rail" hidden>
|
||||
<button type="button" class="collab-rail-handle" id="collab-rail-toggle" data-i18n-title="collab.showProgressTitle" title="">
|
||||
<span class="collab-rail-grip" aria-hidden="true"></span>
|
||||
<svg class="collab-rail-chev" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"/></svg>
|
||||
<span class="collab-rail-badge collab-rail-run" id="collab-rail-run" hidden>
|
||||
<span class="collab-rail-spin" aria-hidden="true"></span>
|
||||
<span class="n" id="collab-rail-run-n">0</span>
|
||||
</span>
|
||||
<span class="collab-rail-badge collab-rail-done" id="collab-rail-done" hidden>
|
||||
<span class="collab-rail-dot" aria-hidden="true"></span>
|
||||
<span class="n" id="collab-rail-done-n">0</span>
|
||||
</span>
|
||||
<span class="collab-rail-badge collab-rail-issue" id="collab-rail-issue" hidden>
|
||||
<span class="collab-rail-dot collab-rail-dot--danger" aria-hidden="true"></span>
|
||||
<span class="n" id="collab-rail-issue-n">0</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<aside class="collab-prog-panel" id="collab-prog-panel">
|
||||
<div class="collab-prog-panel-head">
|
||||
<span data-i18n="collab.progressTitle"></span>
|
||||
<button type="button" class="ic-btn collab-prog-close" id="collab-prog-close" aria-label="close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="collab-prog-stats" id="collab-progress-stats" hidden></div>
|
||||
<div class="collab-progress-empty" id="collab-progress-empty" data-i18n="collab.progressEmpty"></div>
|
||||
<div id="collab-workers"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-quick" id="collab-quick">
|
||||
<button type="button" class="pill" data-prompt-key="collab.chipProgress" data-i18n="collab.chipProgress"></button>
|
||||
<button type="button" class="pill" data-prompt-key="collab.chipPause" data-i18n="collab.chipPause"></button>
|
||||
<button type="button" class="pill" data-prompt-key="collab.chipSummary" data-i18n="collab.chipSummary"></button>
|
||||
</div>
|
||||
<div class="composer-anchor" id="cdb-composer-anchor">
|
||||
<div class="composer" id="cdb-composer" data-composer-ctx="collab">
|
||||
<div class="composer-slot">
|
||||
<div class="composer-inset">
|
||||
<div class="thumb-strip" id="cdb-thumb-strip" hidden></div>
|
||||
<div class="input" id="cdb-input" contenteditable="true" role="textbox" aria-multiline="true" data-i18n-ph="collab.placeholder"></div>
|
||||
<div class="composer-bot">
|
||||
<div class="composer-plus-wrap">
|
||||
<button type="button" class="ic-btn composer-plus" id="cdb-plus-btn" aria-haspopup="menu" aria-expanded="false" data-i18n-title="collab.plusMenu" title="">
|
||||
<span data-ga-icon="plus"></span>
|
||||
</button>
|
||||
<div class="ga-menu composer-menu" id="cdb-menu" hidden role="menu">
|
||||
<button type="button" class="ga-menu-item" data-composer-action="upload" role="menuitem">
|
||||
<span data-ga-icon="paperclip"></span>
|
||||
<span data-i18n="upload.button"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="chip" id="cdb-model-chip" data-i18n-title="model.menuLabel" title="">
|
||||
<span class="model-name"></span>
|
||||
<span class="chip-ic" data-ga-icon="caretDown"></span>
|
||||
</button>
|
||||
<button type="button" class="send" id="cdb-send" aria-label="send">
|
||||
<span data-ga-icon="paperPlaneTilt"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ga-menu" id="cdb-model-menu" hidden></div>
|
||||
<input type="file" id="cdb-file-input" multiple hidden>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Token 统计 -->
|
||||
<section class="page" data-page="token">
|
||||
<div class="page-top">
|
||||
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
|
||||
<span data-ga-icon="caretLeft"></span>
|
||||
</button>
|
||||
<span class="pt-spacer"></span>
|
||||
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
|
||||
<span data-ga-icon="caretRight"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="page-pad">
|
||||
<div class="page-title" data-i18n="page.token.title"></div>
|
||||
<div class="tok-tabs" id="tok-tabs">
|
||||
<button class="tok-tab active" data-tab="chat" data-i18n="tok.tabAll"></button>
|
||||
<button class="tok-tab" data-tab="conductor" data-i18n="tok.tabConductor"></button>
|
||||
</div>
|
||||
<div class="tok-filter">
|
||||
<label data-i18n="tok.from"></label>
|
||||
<input type="text" id="tok-since" class="tok-date" readonly>
|
||||
<label data-i18n="tok.to"></label>
|
||||
<input type="text" id="tok-until" class="tok-date" readonly>
|
||||
<button class="tok-reset" id="tok-reset" data-i18n="tok.reset"></button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="s-n" id="tok-total-n">0</div><div class="s-l" data-i18n="tok.total"></div></div>
|
||||
<div class="stat"><div class="s-n" id="tok-cost-n">0%</div><div class="s-l" data-i18n="tok.cost"></div></div>
|
||||
<div class="stat"><div class="s-n" id="tok-today-n">0</div><div class="s-l" data-i18n="tok.today"></div></div>
|
||||
</div>
|
||||
<table class="tok-table" id="tok-table">
|
||||
<thead><tr>
|
||||
<th data-i18n="tok.colSession"></th>
|
||||
<th data-i18n="tok.colIn"></th>
|
||||
<th data-i18n="tok.colOut"></th>
|
||||
<th data-i18n="tok.colCacheW"></th>
|
||||
<th data-i18n="tok.colCache"></th>
|
||||
<th data-i18n="tok.cost"></th>
|
||||
</tr></thead>
|
||||
<tbody id="tok-tbody"></tbody>
|
||||
</table>
|
||||
<div class="tok-pager" id="tok-pager"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ④ 右侧会话面板 -->
|
||||
<div class="rp-resize" id="rp-resize"></div>
|
||||
<aside class="rightpanel" id="rightpanel">
|
||||
<div class="rp-head">
|
||||
<div class="search">
|
||||
<span class="search-ic" data-ga-icon="magnifyingGlass"></span>
|
||||
<input data-i18n-ph="search.placeholder" placeholder="" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="new-conv">
|
||||
<span data-ga-icon="plus"></span>
|
||||
<span data-i18n="conv.new"></span>
|
||||
</button>
|
||||
<div class="conv-list"></div>
|
||||
|
||||
<!-- 会话操作菜单 -->
|
||||
<div class="ctx-menu" id="conv-menu" hidden>
|
||||
<div class="ctx-item" data-act="pin">
|
||||
<span data-ga-icon="pushPinSimple"></span>
|
||||
<span data-i18n="ctx.pin"></span>
|
||||
</div>
|
||||
<div class="ctx-item" data-act="rename">
|
||||
<span data-ga-icon="pencilSimple"></span>
|
||||
<span data-i18n="ctx.rename"></span>
|
||||
</div>
|
||||
<div class="ctx-item danger" data-act="del">
|
||||
<span data-ga-icon="trash"></span>
|
||||
<span data-i18n="ctx.del"></span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 预设功能 弹窗 -->
|
||||
<div class="modal" id="preset-modal" hidden>
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-card" role="dialog">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" data-i18n="modal.preset"></div>
|
||||
<button id="preset-restore-btn" class="preset-restore" type="button" data-i18n="builtinPreset.restoreBtn" hidden></button>
|
||||
<button class="modal-x" data-close data-i18n-title="common.close" title="">
|
||||
<span data-ga-icon="x"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body preset-pop">
|
||||
<div class="feature-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加模型 弹窗(模板,未实现)-->
|
||||
<div class="modal" id="add-model-modal" hidden>
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-card" role="dialog">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" id="model-form-title" data-i18n="modal.addModel"></div>
|
||||
<button class="modal-x" data-close data-i18n-title="common.close" title="">
|
||||
<span data-ga-icon="x"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body model-form-body">
|
||||
<div class="model-guide" id="model-guide" hidden>
|
||||
<div class="model-guide-head">
|
||||
<span class="model-guide-logo" id="model-guide-logo" aria-hidden="true"></span>
|
||||
<span class="model-guide-name" id="model-guide-name"></span>
|
||||
</div>
|
||||
<ol class="model-guide-steps">
|
||||
<li data-i18n="guide.step1"></li>
|
||||
<li data-i18n="guide.step2"></li>
|
||||
<li data-i18n="guide.step3"></li>
|
||||
</ol>
|
||||
<div class="model-guide-link-row">
|
||||
<a class="model-guide-link" id="model-guide-link" href="#" target="_blank" rel="noopener noreferrer"></a>
|
||||
<button type="button" class="model-guide-copy" id="model-guide-copy" data-i18n-title="guide.copy" title="" aria-label="copy">
|
||||
<span data-ga-icon="copy"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="model-guide-tip" data-i18n="guide.prefillTip"></p>
|
||||
</div>
|
||||
<form id="add-model-form" class="model-form" autocomplete="off">
|
||||
<label><span class="field-label"><span data-i18n="model.model"></span><span class="field-req" aria-hidden="true">*</span></span><input name="model" required data-i18n-ph="model.modelPh" maxlength="50"><span class="field-hint" data-i18n="model.modelHint"></span></label>
|
||||
<label id="model-apikey-label"><span class="field-label"><span data-i18n="model.apikey"></span><span class="field-req" aria-hidden="true">*</span></span><input name="apikey" type="password" data-i18n-ph="model.apikeyPh" id="model-apikey-input" maxlength="200"></label>
|
||||
<label><span class="field-label"><span data-i18n="model.apibase"></span><span class="field-req" aria-hidden="true">*</span></span><input name="apibase" required data-i18n-ph="model.apibasePh" maxlength="200"></label>
|
||||
<div class="model-form-field"><span class="field-label"><span data-i18n="model.protocol"></span><span class="field-req" aria-hidden="true">*</span></span>
|
||||
<div class="seg-group" role="radiogroup">
|
||||
<label class="seg-opt"><input type="radio" name="protocol" value="oai" required><span data-i18n="model.protocolOai"></span></label>
|
||||
<label class="seg-opt"><input type="radio" name="protocol" value="claude" required><span data-i18n="model.protocolClaude"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-form-field"><span class="field-label"><span data-i18n="model.stream"></span></span>
|
||||
<div class="seg-group" role="radiogroup">
|
||||
<label class="seg-opt"><input type="radio" name="stream" value="true" checked><span data-i18n="model.streamOn"></span></label>
|
||||
<label class="seg-opt"><input type="radio" name="stream" value="false"><span data-i18n="model.streamOff"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<label><span data-i18n="model.name"></span><input name="name" data-i18n-ph="model.namePh" data-optional-ph maxlength="50"></label>
|
||||
<div class="model-form-row">
|
||||
<label class="model-form-mini"><span data-i18n="model.retries"></span><input name="max_retries" type="text" inputmode="numeric" pattern="[0-9]*" min="1" max="20" value="5"></label>
|
||||
<label class="model-form-mini"><span data-i18n="model.connTimeout"></span><input name="connect_timeout" type="text" inputmode="numeric" pattern="[0-9]*" min="1" max="300" value="15"></label>
|
||||
<label class="model-form-mini"><span data-i18n="model.readTimeout"></span><input name="read_timeout" type="text" inputmode="numeric" pattern="[0-9]*" min="5" max="3600" value="300"></label>
|
||||
</div>
|
||||
<p class="model-form-err" id="add-model-err" hidden></p>
|
||||
<div class="model-form-actions">
|
||||
<button type="button" class="set-btn ghost" data-close data-i18n="common.cancel"></button>
|
||||
<button type="submit" class="set-btn primary"><span data-i18n="model.save"></span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="confirm-modal" hidden>
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-card confirm-card" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" id="confirm-title"></div>
|
||||
<button class="modal-x" data-close data-i18n-title="common.close" title="">
|
||||
<span data-ga-icon="x"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body confirm-body">
|
||||
<div class="confirm-message" id="confirm-message"></div>
|
||||
<div class="confirm-actions">
|
||||
<button type="button" class="set-btn ghost" id="confirm-cancel"></button>
|
||||
<button type="button" class="set-btn primary" id="confirm-ok"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义预设 弹窗 -->
|
||||
<div class="modal" id="custom-preset-modal" hidden>
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-card" role="dialog">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" data-i18n="modal.customPreset"></div>
|
||||
<button class="modal-x" data-close data-i18n-title="common.close" title="">
|
||||
<span data-ga-icon="x"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body cp-body">
|
||||
<input class="cp-input" id="cp-title" type="text" data-i18n-ph="customPreset.titlePh" placeholder="" maxlength="50">
|
||||
<textarea class="cp-textarea" id="cp-prompt" rows="6" data-i18n-ph="customPreset.promptPh" placeholder="" maxlength="10000"></textarea>
|
||||
<div class="cp-error" id="cp-error" hidden></div>
|
||||
<div class="cp-actions">
|
||||
<button class="cp-btn" data-close data-i18n="common.cancel" type="button"></button>
|
||||
<button class="cp-btn cp-btn-primary" id="cp-save" data-i18n="common.save" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 渠道日志 弹窗 -->
|
||||
<div class="modal" id="chan-log-modal" hidden>
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-card modal-card-wide modal-card-inset" role="dialog">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" id="chan-log-title" data-i18n="modal.channelLogs"></div>
|
||||
<button class="modal-x" data-close data-i18n-title="common.close" title="">
|
||||
<span data-ga-icon="x"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body modal-body-inset">
|
||||
<pre class="chan-log-pre" id="chan-log-pre"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mykey 配置 弹窗 -->
|
||||
<div class="modal" id="chan-config-modal" hidden>
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-card modal-card-wide modal-card-inset" role="dialog">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" id="chan-config-title" data-i18n="modal.mykeyConfig"></div>
|
||||
<button class="modal-x" data-close data-i18n-title="common.close" title="">
|
||||
<span data-ga-icon="x"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body modal-body-inset chan-config-body">
|
||||
<textarea id="chan-config-editor" class="mykey-editor" spellcheck="false" maxlength="1000000"></textarea>
|
||||
<div class="chan-config-foot">
|
||||
<button type="button" class="chan-config-save" id="chan-config-save" data-i18n="common.save"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置 弹窗 -->
|
||||
<div class="modal" id="settings-modal" hidden>
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-card" role="dialog">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" data-i18n="modal.settings"></div>
|
||||
<button class="modal-x" data-close data-i18n-title="common.close" title="">
|
||||
<span data-ga-icon="x"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body set-body">
|
||||
<div class="set-block">
|
||||
<div class="set-sec-t" data-i18n="set.appearance"></div>
|
||||
<div class="appear-cards" id="appearance-seg" role="radiogroup">
|
||||
<button class="appear-card sel" type="button" data-appearance="light" role="radio" aria-checked="true">
|
||||
<span class="appear-preview appear-preview-light" aria-hidden="true"></span>
|
||||
<span class="appear-name" data-i18n="appearance.light"></span>
|
||||
</button>
|
||||
<button class="appear-card" type="button" data-appearance="dark" role="radio" aria-checked="false">
|
||||
<span class="appear-preview appear-preview-dark" aria-hidden="true"></span>
|
||||
<span class="appear-name" data-i18n="appearance.dark"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="set-toggle-row" id="plain-ui-row" hidden>
|
||||
<span class="set-toggle-label" data-i18n="set.plainUi"></span>
|
||||
<button class="set-switch" type="button" id="plain-ui-switch" role="switch" aria-checked="false">
|
||||
<span class="set-switch-knob" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-block">
|
||||
<div class="set-sec-t" id="chat-font-label" data-i18n="set.fontSize"></div>
|
||||
<div class="chat-font-panel">
|
||||
<div class="chat-font-stepper" id="chat-font-stepper" role="slider"
|
||||
aria-valuemin="10" aria-valuemax="20" aria-valuenow="14"
|
||||
aria-valuetext="14px" aria-labelledby="chat-font-label" tabindex="0">
|
||||
<div class="chat-font-segments" id="chat-font-segments"></div>
|
||||
</div>
|
||||
<span class="chat-font-value" id="chat-font-value" aria-hidden="true">14px</span>
|
||||
<div class="chat-font-scale" aria-hidden="true">
|
||||
<span class="chat-font-scale-min">10</span>
|
||||
<span class="chat-font-scale-max">20</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-block">
|
||||
<div class="set-sec-t" data-i18n="set.lang"></div>
|
||||
<div class="model-list lang-list" id="lang-list"></div>
|
||||
</div>
|
||||
<div class="set-block">
|
||||
<div class="set-sec-t" data-i18n="set.model"></div>
|
||||
<div class="model-list" id="model-list"></div>
|
||||
<button class="set-btn" id="add-model-btn" type="button">
|
||||
<span data-ga-icon="plus"></span>
|
||||
<span data-i18n="set.addModel"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="set-block">
|
||||
<div class="set-sec-t" data-i18n="set.features"></div>
|
||||
<button class="set-btn" id="import-mykey-btn" type="button">
|
||||
<span data-ga-icon="fileText"></span>
|
||||
<span data-i18n="set.importMykey"></span>
|
||||
</button>
|
||||
<input type="file" id="import-mykey-input" accept=".py,text/plain" hidden>
|
||||
<button class="set-btn set-btn-follow" id="export-mykey-btn" type="button">
|
||||
<span data-ga-icon="folderSimple"></span>
|
||||
<span data-i18n="set.exportMykey"></span>
|
||||
</button>
|
||||
<button class="set-btn set-btn-follow" id="settings-services-btn" type="button">
|
||||
<span data-ga-icon="broadcast"></span>
|
||||
<span data-i18n="set.serviceManager"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览 lightbox -->
|
||||
<div class="lightbox" id="lightbox" hidden>
|
||||
<div class="lightbox-backdrop" data-close></div>
|
||||
<button class="lightbox-x" data-close data-i18n-title="lightbox.closeTitle" title="">×</button>
|
||||
<img class="lightbox-img" id="lightbox-img" alt="">
|
||||
</div>
|
||||
|
||||
<div id="toast-root" class="toast-root" aria-live="polite"></div>
|
||||
|
||||
<script src="vendor/marked.min.js"></script>
|
||||
<!-- KaTeX JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
|
||||
<!-- highlight.js -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script>
|
||||
<script src="phosphor-icons.js?v=2"></script>
|
||||
<script src="app.js?v=208"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>GenericAgent</title>
|
||||
<style>
|
||||
:root{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI","Microsoft YaHei UI","Helvetica Neue",Helvetica,Arial,sans-serif;background:#fff;color:#111}
|
||||
@media(prefers-color-scheme:dark){:root{background:#1a1a1a;color:#eee}}
|
||||
html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center}
|
||||
.card{text-align:center;opacity:.85;width:280px}
|
||||
.spinner{width:28px;height:28px;margin:0 auto 14px;border:3px solid #9994;border-top-color:currentColor;border-radius:50%;animation:spin 1s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
#ga-status{font-size:13px;line-height:1.5}
|
||||
.bar{display:none;width:240px;height:6px;margin:14px auto 0;background:#9993;border-radius:99px;overflow:hidden}
|
||||
.bar.show{display:block}
|
||||
#ga-bar{height:100%;width:0;background:currentColor;border-radius:99px;transition:width .3s ease}
|
||||
#ga-bar.indet{width:35%;animation:indet 1.1s ease-in-out infinite}
|
||||
@keyframes indet{0%{margin-left:-35%}100%{margin-left:100%}}
|
||||
</style>
|
||||
<script>
|
||||
// Loading window shows before the main UI, on a different origin than the app, so it can't
|
||||
// read the app's saved language; fall back to the system/browser locale.
|
||||
var GA_ZH = (navigator.language || '').toLowerCase().indexOf('zh') === 0;
|
||||
var GA_MSG = {
|
||||
starting_app: GA_ZH ? '正在启动 GenericAgent…' : 'Starting GenericAgent…',
|
||||
start: GA_ZH ? '首次启动,正在准备运行环境…' : 'First run: preparing the runtime…',
|
||||
venv: GA_ZH ? '正在创建运行环境…' : 'Creating the runtime environment…',
|
||||
deps: GA_ZH ? '正在安装依赖…' : 'Installing dependencies…',
|
||||
done: GA_ZH ? '依赖安装完成' : 'Dependencies installed',
|
||||
starting: GA_ZH ? '正在启动服务…' : 'Starting services…'
|
||||
};
|
||||
window.gaProgress = function(pct, key){
|
||||
var s = document.getElementById('ga-status');
|
||||
if (s) s.textContent = GA_MSG[key] || key;
|
||||
var bar = document.querySelector('.bar');
|
||||
var fill = document.getElementById('ga-bar');
|
||||
if (bar) bar.classList.add('show');
|
||||
if (fill){
|
||||
if (pct < 0){ fill.classList.add('indet'); }
|
||||
else { fill.classList.remove('indet'); fill.style.width = Math.max(0, Math.min(100, pct)) + '%'; }
|
||||
}
|
||||
};
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
var s = document.getElementById('ga-status');
|
||||
if (s) s.textContent = GA_MSG.starting_app;
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<div id="ga-status">正在启动 GenericAgent…</div>
|
||||
<div class="bar"><div id="ga-bar"></div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
(() => {
|
||||
const PATHS = {
|
||||
chatTeardropText:
|
||||
'M172,112a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h68A8,8,0,0,1,172,112Zm-8,24H96a8,8,0,0,0,0,16h68a8,8,0,0,0,0-16Zm68-12A100.11,100.11,0,0,1,132,224H48a16,16,0,0,1-16-16V124a100,100,0,0,1,200,0Zm-16,0a84,84,0,0,0-168,0v84h84A84.09,84.09,0,0,0,216,124Z',
|
||||
broadcast:
|
||||
'M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z',
|
||||
chartBar:
|
||||
'M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z',
|
||||
usersThree:
|
||||
'M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z',
|
||||
coins:
|
||||
'M184,89.57V84c0-25.08-37.83-44-88-44S8,58.92,8,84v40c0,20.89,26.25,37.49,64,42.46V172c0,25.08,37.83,44,88,44s88-18.92,88-44V132C248,111.3,222.58,94.68,184,89.57ZM232,132c0,13.22-30.79,28-72,28-3.73,0-7.43-.13-11.08-.37C170.49,151.77,184,139,184,124V105.74C213.87,110.19,232,122.27,232,132ZM72,150.25V126.46A183.74,183.74,0,0,0,96,128a183.74,183.74,0,0,0,24-1.54v23.79A163,163,0,0,1,96,152,163,163,0,0,1,72,150.25Zm96-40.32V124c0,8.39-12.41,17.4-32,22.87V123.5C148.91,120.37,159.84,115.71,168,109.93ZM96,56c41.21,0,72,14.78,72,28s-30.79,28-72,28S24,97.22,24,84,54.79,56,96,56ZM24,124V109.93c8.16,5.78,19.09,10.44,32,13.57v23.37C36.41,141.4,24,132.39,24,124Zm64,48v-4.17c2.63.1,5.29.17,8,.17,3.88,0,7.67-.13,11.39-.35A121.92,121.92,0,0,0,120,171.41v23.46C100.41,189.4,88,180.39,88,172Zm48,26.25V174.4a179.48,179.48,0,0,0,24,1.6,183.74,183.74,0,0,0,24-1.54v23.79a165.45,165.45,0,0,1-48,0Zm64-3.38V171.5c12.91-3.13,23.84-7.79,32-13.57V172C232,180.39,219.59,189.4,200,194.87Z',
|
||||
sidebarSimple:
|
||||
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,56H80V200H40ZM216,200H96V56H216V200Z',
|
||||
pencilSimple:
|
||||
'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z',
|
||||
magnifyingGlass:
|
||||
'M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z',
|
||||
books:
|
||||
'M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z',
|
||||
gridFour:
|
||||
'M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z',
|
||||
robot:
|
||||
'M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z',
|
||||
dotsThree:
|
||||
'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Z',
|
||||
folderSimplePlus:
|
||||
'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Zm-56-56a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,144Z',
|
||||
folderSimple:
|
||||
'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Z',
|
||||
bracketsCurly:
|
||||
'M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z',
|
||||
gear:
|
||||
'M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z',
|
||||
caretLeft:
|
||||
'M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z',
|
||||
caretDown:
|
||||
'M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z',
|
||||
caretRight:
|
||||
'M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z',
|
||||
paperPlaneTilt:
|
||||
'M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z',
|
||||
paperclip:
|
||||
'M209.66,122.34a8,8,0,0,1,0,11.32l-82.05,82a56,56,0,0,1-79.2-79.21L147.67,35.73a40,40,0,1,1,56.61,56.55L105,193A24,24,0,1,1,71,159L154.3,74.38A8,8,0,1,1,165.7,85.6L82.39,170.31a8,8,0,1,0,11.27,11.36L192.93,81A24,24,0,1,0,159,47L59.76,147.68a40,40,0,1,0,56.53,56.62l82.06-82A8,8,0,0,1,209.66,122.34Z',
|
||||
lightning:
|
||||
'M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z',
|
||||
pushPinSimple:
|
||||
'M216,168h-9.29L185.54,48H192a8,8,0,0,0,0-16H64a8,8,0,0,0,0,16h6.46L49.29,168H40a8,8,0,0,0,0,16h80v56a8,8,0,0,0,16,0V184h80a8,8,0,0,0,0-16ZM86.71,48h82.58l21.17,120H65.54Z',
|
||||
trash:
|
||||
'M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z',
|
||||
x:
|
||||
'M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z',
|
||||
dotsThreeVertical:
|
||||
'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm0-56a12,12,0,1,1-12-12A12,12,0,0,1,140,72Zm0,112a12,12,0,1,1-12-12A12,12,0,0,1,140,184Z',
|
||||
plus:
|
||||
'M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z',
|
||||
copy:
|
||||
'M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z',
|
||||
check:
|
||||
'M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z',
|
||||
crosshair:
|
||||
'M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z',
|
||||
compass:
|
||||
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216ZM172.42,72.84l-64,32a8.05,8.05,0,0,0-3.58,3.58l-32,64A8,8,0,0,0,80,184a8.1,8.1,0,0,0,3.58-.84l64-32a8.05,8.05,0,0,0,3.58-3.58l32-64a8,8,0,0,0-10.74-10.74ZM138,138,97.89,158.11,118,118l40.15-20.07Z',
|
||||
listChecks:
|
||||
'M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z',
|
||||
star:
|
||||
'M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z',
|
||||
fileText:
|
||||
'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z',
|
||||
gitFork:
|
||||
'M224,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V95a32,32,0,1,0-16,0v17a24,24,0,0,0,24,24h40v25a32,32,0,1,0,16,0V136h40a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,224,64ZM48,64A16,16,0,1,1,64,80,16,16,0,0,1,48,64Zm96,128a16,16,0,1,1-16-16A16,16,0,0,1,144,192ZM192,80a16,16,0,1,1,16-16A16,16,0,0,1,192,80Z',
|
||||
hexagon:
|
||||
'M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM216,175.82,128,224,40,175.82V80.18L128,32h0l88,48.17Z',
|
||||
};
|
||||
|
||||
function gaIcon(name, className = '') {
|
||||
const d = PATHS[name];
|
||||
if (!d) return '';
|
||||
const cls = className ? ` class="${className}"` : '';
|
||||
return `<svg${cls} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true" focusable="false"><path d="${d}"/></svg>`;
|
||||
}
|
||||
|
||||
function gaHydrateIcons(root = document) {
|
||||
root.querySelectorAll('[data-ga-icon]').forEach((node) => {
|
||||
node.outerHTML = gaIcon(node.dataset.gaIcon, node.className || '');
|
||||
});
|
||||
}
|
||||
|
||||
window.GA_ICON_PATHS = PATHS;
|
||||
window.gaIcon = gaIcon;
|
||||
window.gaHydrateIcons = gaHydrateIcons;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const hydrate = () => gaHydrateIcons(document);
|
||||
hydrate();
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', hydrate, { once: true });
|
||||
}
|
||||
}
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user