diff --git a/crates/agent-mobile/.gitignore b/crates/agent-mobile/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/crates/agent-mobile/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/crates/agent-mobile/README.md b/crates/agent-mobile/README.md new file mode 100644 index 000000000..12920b6b6 --- /dev/null +++ b/crates/agent-mobile/README.md @@ -0,0 +1,7 @@ +# Tauri + Vue + TypeScript + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/crates/agent-mobile/package.json b/crates/agent-mobile/package.json new file mode 100644 index 000000000..c33b6272b --- /dev/null +++ b/crates/agent-mobile/package.json @@ -0,0 +1,65 @@ +{ + "name": "@liveagent/mobile", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "biome check src/", + "tauri": "tauri", + "format": "biome format --write src/", + "lint:fix": "biome check --write src/" + }, + "dependencies": { + "@base-ui/react": "^1.7.0", + "@bufbuild/protobuf": "^2.14.0", + "@earendil-works/pi-ai": "^0.80.10", + "@git-diff-view/file": "^0.1.7", + "@git-diff-view/react": "^0.1.7", + "@iconify-json/gravity-ui": "^1.2.14", + "@liveagent/ui": "workspace:*", + "@sinclair/typebox": "^0.34.52", + "@streamdown/cjk": "^1.0.3", + "@streamdown/code": "^1.1.1", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", + "@tanstack/react-virtual": "^3.14.9", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-opener": "^2.5.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "katex": "^0.17.0", + "monaco-editor": "^0.55.1", + "react": "^19.2.8", + "react-complex-tree": "^2.6.2", + "react-dom": "^19.2.8", + "remark-breaks": "^4.0.0", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.6.0", + "yet-another-react-lightbox": "^3.32.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "@bufbuild/protoc-gen-es": "2.12.1", + "@iconify-json/logos": "^1.2.12", + "@iconify-json/lucide": "^1.2.123", + "@iconify-json/material-icon-theme": "1.2.67", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", + "@tailwindcss/postcss": "4.2.2", + "@tailwindcss/typography": "^0.5.20", + "@tauri-apps/cli": "^2.11.4", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "postcss": "^8.5.26", + "tailwindcss": "4.2.2", + "typescript": "6.0.2", + "unplugin-icons": "^23.0.1", + "vite": "^8.2.1" + } +} \ No newline at end of file diff --git a/crates/agent-mobile/postcss.config.js b/crates/agent-mobile/postcss.config.js new file mode 100644 index 000000000..c2ddf7482 --- /dev/null +++ b/crates/agent-mobile/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/crates/agent-mobile/public/favicon.svg b/crates/agent-mobile/public/favicon.svg new file mode 100644 index 000000000..bd918a1b6 --- /dev/null +++ b/crates/agent-mobile/public/favicon.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/agent-mobile/public/icon-simple.png b/crates/agent-mobile/public/icon-simple.png new file mode 100644 index 000000000..d2b6468ca Binary files /dev/null and b/crates/agent-mobile/public/icon-simple.png differ diff --git a/crates/agent-mobile/public/icon.png b/crates/agent-mobile/public/icon.png new file mode 100644 index 000000000..4259daa4c Binary files /dev/null and b/crates/agent-mobile/public/icon.png differ diff --git a/crates/agent-mobile/src-tauri/.gitignore b/crates/agent-mobile/src-tauri/.gitignore new file mode 100644 index 000000000..b21bd681d --- /dev/null +++ b/crates/agent-mobile/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/crates/agent-mobile/src-tauri/Cargo.toml b/crates/agent-mobile/src-tauri/Cargo.toml new file mode 100644 index 000000000..fd57ab7aa --- /dev/null +++ b/crates/agent-mobile/src-tauri/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "liveagent_mobile" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "liveagent_mobile_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + diff --git a/crates/agent-mobile/src-tauri/build.rs b/crates/agent-mobile/src-tauri/build.rs new file mode 100644 index 000000000..d860e1e6a --- /dev/null +++ b/crates/agent-mobile/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/crates/agent-mobile/src-tauri/capabilities/default.json b/crates/agent-mobile/src-tauri/capabilities/default.json new file mode 100644 index 000000000..4cdbf49a7 --- /dev/null +++ b/crates/agent-mobile/src-tauri/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default" + ] +} diff --git a/crates/agent-mobile/src-tauri/gen/android/.editorconfig b/crates/agent-mobile/src-tauri/gen/android/.editorconfig new file mode 100644 index 000000000..ebe51d3bf --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/.gitignore b/crates/agent-mobile/src-tauri/gen/android/.gitignore new file mode 100644 index 000000000..1c636c39a --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +keystore.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/.gitignore b/crates/agent-mobile/src-tauri/gen/android/app/.gitignore new file mode 100644 index 000000000..6c4d56b4a --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/build.gradle.kts b/crates/agent-mobile/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 000000000..8b0be512c --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "com.xiaofei.liveagent" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "com.xiaofei.liveagent" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/proguard-rules.pro b/crates/agent-mobile/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..504515f86 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/java/com/xiaofei/liveagent/MainActivity.kt b/crates/agent-mobile/src-tauri/gen/android/app/src/main/java/com/xiaofei/liveagent/MainActivity.kt new file mode 100644 index 000000000..38197602f --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/java/com/xiaofei/liveagent/MainActivity.kt @@ -0,0 +1,11 @@ +package com.xiaofei.liveagent + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } +} diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..4fc244418 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..2ffbf24b6 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..494d711ea Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..7c6bfcba9 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..32c1fa961 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..a6a8ff95d Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..9be1c8251 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..e9f6e9ea8 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..7e0cfc3bd Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..35a68d447 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..16960dd30 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..4ae82c4de Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..6b57acf8a Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..e274ac618 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..de089fa13 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..7bbbb55fb Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..4ca519203 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..58d04a39b --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/colors.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..ea9c223a6 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..ba2324f43 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + "liveagent" + "liveagent" + \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/themes.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..58d04a39b --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..782d63b99 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/agent-mobile/src-tauri/gen/android/build.gradle.kts b/crates/agent-mobile/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 000000000..607240bc8 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/crates/agent-mobile/src-tauri/gen/android/buildSrc/build.gradle.kts b/crates/agent-mobile/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 000000000..5c55bba71 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/crates/agent-mobile/src-tauri/gen/android/buildSrc/src/main/java/com/xiaofei/liveagent/kotlin/BuildTask.kt b/crates/agent-mobile/src-tauri/gen/android/buildSrc/src/main/java/com/xiaofei/liveagent/kotlin/BuildTask.kt new file mode 100644 index 000000000..a1b43c07a --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/buildSrc/src/main/java/com/xiaofei/liveagent/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """cargo"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/buildSrc/src/main/java/com/xiaofei/liveagent/kotlin/RustPlugin.kt b/crates/agent-mobile/src-tauri/gen/android/buildSrc/src/main/java/com/xiaofei/liveagent/kotlin/RustPlugin.kt new file mode 100644 index 000000000..4aa7fcaf6 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/buildSrc/src/main/java/com/xiaofei/liveagent/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/gradle.properties b/crates/agent-mobile/src-tauri/gen/android/gradle.properties new file mode 100644 index 000000000..2a7ec6959 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/crates/agent-mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/crates/agent-mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e708b1c02 Binary files /dev/null and b/crates/agent-mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/crates/agent-mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/crates/agent-mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..c5f9a53c2 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/crates/agent-mobile/src-tauri/gen/android/gradlew b/crates/agent-mobile/src-tauri/gen/android/gradlew new file mode 100644 index 000000000..4f906e0c8 --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/crates/agent-mobile/src-tauri/gen/android/gradlew.bat b/crates/agent-mobile/src-tauri/gen/android/gradlew.bat new file mode 100644 index 000000000..107acd32c --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/crates/agent-mobile/src-tauri/gen/android/settings.gradle b/crates/agent-mobile/src-tauri/gen/android/settings.gradle new file mode 100644 index 000000000..39391166f --- /dev/null +++ b/crates/agent-mobile/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/crates/agent-mobile/src-tauri/icons/128x128.png b/crates/agent-mobile/src-tauri/icons/128x128.png new file mode 100644 index 000000000..0087a23cd Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/128x128.png differ diff --git a/crates/agent-mobile/src-tauri/icons/128x128@2x.png b/crates/agent-mobile/src-tauri/icons/128x128@2x.png new file mode 100644 index 000000000..a8b1432c9 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/128x128@2x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/32x32.png b/crates/agent-mobile/src-tauri/icons/32x32.png new file mode 100644 index 000000000..664f17529 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/32x32.png differ diff --git a/crates/agent-mobile/src-tauri/icons/64x64.png b/crates/agent-mobile/src-tauri/icons/64x64.png new file mode 100644 index 000000000..c7d30bad9 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/64x64.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square107x107Logo.png b/crates/agent-mobile/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 000000000..6569301d7 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square107x107Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square142x142Logo.png b/crates/agent-mobile/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 000000000..cca9f6697 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square142x142Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square150x150Logo.png b/crates/agent-mobile/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 000000000..3d0202a34 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square150x150Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square284x284Logo.png b/crates/agent-mobile/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 000000000..bc37fa3a2 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square284x284Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square30x30Logo.png b/crates/agent-mobile/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 000000000..1305aaea1 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square30x30Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square310x310Logo.png b/crates/agent-mobile/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 000000000..155449705 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square310x310Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square44x44Logo.png b/crates/agent-mobile/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 000000000..73c9f0c1c Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square44x44Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square71x71Logo.png b/crates/agent-mobile/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 000000000..a45dad629 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square71x71Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/Square89x89Logo.png b/crates/agent-mobile/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 000000000..9cde1e5b0 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/Square89x89Logo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/StoreLogo.png b/crates/agent-mobile/src-tauri/icons/StoreLogo.png new file mode 100644 index 000000000..046afa926 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/StoreLogo.png differ diff --git a/crates/agent-mobile/src-tauri/icons/icon.icns b/crates/agent-mobile/src-tauri/icons/icon.icns new file mode 100644 index 000000000..378b573d2 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/icon.icns differ diff --git a/crates/agent-mobile/src-tauri/icons/icon.ico b/crates/agent-mobile/src-tauri/icons/icon.ico new file mode 100644 index 000000000..cb081381e Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/icon.ico differ diff --git a/crates/agent-mobile/src-tauri/icons/icon.png b/crates/agent-mobile/src-tauri/icons/icon.png new file mode 100644 index 000000000..8353790b7 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/icon.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@1x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 000000000..6f1f9a188 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 000000000..575dc4c19 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@2x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 000000000..575dc4c19 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@3x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 000000000..b243dcf29 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@1x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 000000000..7f01ac38e Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 000000000..38b5afdf4 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@2x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 000000000..38b5afdf4 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@3x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 000000000..9a385d82c Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@1x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 000000000..575dc4c19 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 000000000..c8daebb6f Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@2x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 000000000..c8daebb6f Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@3x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 000000000..18f949efc Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-512@2x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 000000000..88f2bb289 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-60x60@2x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 000000000..18f949efc Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-60x60@3x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 000000000..b0802bdba Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-76x76@1x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 000000000..b3f08b8fc Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-76x76@2x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 000000000..84005a383 Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/crates/agent-mobile/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 000000000..70eb4fe1f Binary files /dev/null and b/crates/agent-mobile/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/crates/agent-mobile/src-tauri/src/lib.rs b/crates/agent-mobile/src-tauri/src/lib.rs new file mode 100644 index 000000000..4a277ef35 --- /dev/null +++ b/crates/agent-mobile/src-tauri/src/lib.rs @@ -0,0 +1,14 @@ +// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +#[tauri::command] +fn greet(name: &str) -> String { + format!("Hello, {}! You've been greeted from Rust!", name) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .invoke_handler(tauri::generate_handler![greet]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/crates/agent-mobile/src-tauri/src/main.rs b/crates/agent-mobile/src-tauri/src/main.rs new file mode 100644 index 000000000..84a4118f9 --- /dev/null +++ b/crates/agent-mobile/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + liveagent_mobile_lib::run() +} diff --git a/crates/agent-mobile/src-tauri/tauri.conf.json b/crates/agent-mobile/src-tauri/tauri.conf.json new file mode 100644 index 000000000..e9c75d0f0 --- /dev/null +++ b/crates/agent-mobile/src-tauri/tauri.conf.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "liveagent", + "version": "0.1.0", + "identifier": "com.xiaofei.liveagent", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "liveagent", + "width": 800, + "height": 600 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} \ No newline at end of file diff --git a/crates/agent-mobile/src/App.tsx b/crates/agent-mobile/src/App.tsx new file mode 100644 index 000000000..eca7bf60f --- /dev/null +++ b/crates/agent-mobile/src/App.tsx @@ -0,0 +1,3 @@ +import GatewayApp from "@/app/GatewayApp"; + +export default GatewayApp; diff --git a/crates/agent-mobile/src/agent-ui-adapters/assistantAvatar.ts b/crates/agent-mobile/src/agent-ui-adapters/assistantAvatar.ts new file mode 100644 index 000000000..7aea6a164 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/assistantAvatar.ts @@ -0,0 +1,3 @@ +export function getAssistantAvatarUrl() { + return "/icon-simple.png"; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/assistantBubble.ts b/crates/agent-mobile/src/agent-ui-adapters/assistantBubble.ts new file mode 100644 index 000000000..902ae2862 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/assistantBubble.ts @@ -0,0 +1,25 @@ +import type { AskUserQuestionAnswer } from "@liveagent/ui/lib/chat/askUserQuestion"; +import { readAskUserQuestionDeadlineAt } from "@liveagent/ui/lib/chat/askUserQuestion"; +import { readToolApprovalPending } from "@liveagent/ui/lib/chat/toolApprovalArgs"; +import { submitAskUserQuestionAnswer } from "../lib/chat/askUserQuestionBridge"; + +export const deferLargeToolImages = true; +export const retainRunningToolContent = false; + +export function usePendingToolApproval( + _toolCallId: string, + toolArguments: Record, +) { + return readToolApprovalPending(toolArguments); +} + +export function readAskUserQuestionDeadline( + _toolCallId: string, + toolArguments: Record, +) { + return readAskUserQuestionDeadlineAt(toolArguments) ?? undefined; +} + +export function submitAskUserQuestionAnswers(toolCallId: string, answers: AskUserQuestionAnswer[]) { + return submitAskUserQuestionAnswer(toolCallId, answers); +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/assistantStatus.ts b/crates/agent-mobile/src/agent-ui-adapters/assistantStatus.ts new file mode 100644 index 000000000..63fdf3441 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/assistantStatus.ts @@ -0,0 +1 @@ +export const assistantStatusSpinnerClassName = ""; diff --git a/crates/agent-mobile/src/agent-ui-adapters/chatHeaderChrome.ts b/crates/agent-mobile/src/agent-ui-adapters/chatHeaderChrome.ts new file mode 100644 index 000000000..4e06e706f --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/chatHeaderChrome.ts @@ -0,0 +1,3 @@ +export function isDesktopChatHeaderInset() { + return false; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/composerClipboard.ts b/crates/agent-mobile/src/agent-ui-adapters/composerClipboard.ts new file mode 100644 index 000000000..63ee576b6 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/composerClipboard.ts @@ -0,0 +1,5 @@ +export const usesCustomComposerContextMenu = false; + +export async function readComposerClipboardText() { + return null; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/directoryPicker.tsx b/crates/agent-mobile/src/agent-ui-adapters/directoryPicker.tsx new file mode 100644 index 000000000..9d937d937 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/directoryPicker.tsx @@ -0,0 +1,11 @@ +import { useRemotePathPicker } from "../components/RemotePathPickerModal"; + +export function useDirectoryPicker() { + const { pickPath, pathPickerElement } = useRemotePathPicker(); + return { + async pickDirectory(initialPath: string): Promise { + return await pickPath({ mode: "directory", initialPath }); + }, + directoryPickerElement: pathPickerElement, + }; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/hubChrome.tsx b/crates/agent-mobile/src/agent-ui-adapters/hubChrome.tsx new file mode 100644 index 000000000..43f1d6889 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/hubChrome.tsx @@ -0,0 +1,5 @@ +export const usesOverlayTitleBar = false; + +export function HubTitleBar() { + return null; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/memoryOrganizer.ts b/crates/agent-mobile/src/agent-ui-adapters/memoryOrganizer.ts new file mode 100644 index 000000000..55fe472f7 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/memoryOrganizer.ts @@ -0,0 +1,5 @@ +export const canRunOrganizerLocally = false; + +export function pokeMemoryOrganizer() { + return false; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/providerSettings.tsx b/crates/agent-mobile/src/agent-ui-adapters/providerSettings.tsx new file mode 100644 index 000000000..bacc9da82 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/providerSettings.tsx @@ -0,0 +1,10 @@ +import type { AppSettings, ProviderId } from "../lib/settings"; +import type { SettingsSectionProps } from "../pages/settings/types"; + +export function ProviderSettingsExtension(_props: { + activeTab: ProviderId; + settings: AppSettings; + setSettings: SettingsSectionProps["setSettings"]; +}) { + return null; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/settingsExtension.tsx b/crates/agent-mobile/src/agent-ui-adapters/settingsExtension.tsx new file mode 100644 index 000000000..38800a9e6 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/settingsExtension.tsx @@ -0,0 +1,29 @@ +import { MonitorSmartphone } from "@liveagent/ui/components/IconSet"; +import type { SettingsSectionDefinition, UiExtensionSlots } from "@liveagent/ui/contracts/registry"; +import { DevicesSection } from "../pages/settings/DevicesSection"; +import type { SettingsPageProps } from "../pages/settings/types"; + +export function createSettingsExtension(props: SettingsPageProps): { + surface: "web"; + iconClassName: string; + slots: UiExtensionSlots; + sections: SettingsSectionDefinition[]; +} { + return { + surface: "web", + iconClassName: "h-4 w-4", + slots: {}, + sections: [ + { + id: "devices", + groupKey: "settings.groupConnectivity", + groupOrder: 40, + order: 30, + labelKey: "settings.navAgentManagement", + icon: , + showSaveIndicator: false, + render: () => , + }, + ], + }; +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/sshLocalForwardClient.ts b/crates/agent-mobile/src/agent-ui-adapters/sshLocalForwardClient.ts new file mode 100644 index 000000000..c6af8cf8f --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/sshLocalForwardClient.ts @@ -0,0 +1 @@ +export { gatewaySshLocalForwardClient as sshLocalForwardClient } from "../lib/terminal/gatewaySshLocalForwardClient"; diff --git a/crates/agent-mobile/src/agent-ui-adapters/systemSettings.tsx b/crates/agent-mobile/src/agent-ui-adapters/systemSettings.tsx new file mode 100644 index 000000000..662764e62 --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/systemSettings.tsx @@ -0,0 +1,14 @@ +export { + buildFontFamilySelectOptions, + FONT_FAMILY_CUSTOM_SELECT_VALUE, + FONT_FAMILY_DEFAULT_SELECT_VALUE, + fromFontFamilySelectValue, + listLocalFontFamilies, + toFontFamilySelectValue, +} from "@liveagent/ui/lib/shared/fontFamily"; + +export function SystemSettingsExtensions(_props: SettingsSectionProps) { + return null; +} + +import type { SettingsSectionProps } from "../pages/settings/types"; diff --git a/crates/agent-mobile/src/agent-ui-adapters/userMessageContent.ts b/crates/agent-mobile/src/agent-ui-adapters/userMessageContent.ts new file mode 100644 index 000000000..16176981f --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/userMessageContent.ts @@ -0,0 +1,14 @@ +import { tokenizeUserMessage as tokenizeSharedUserMessage } from "@liveagent/ui/lib/chat/userMessageContent"; + +export { + extractGitHubCommitSha, + extractGitHubFileReference, +} from "@liveagent/ui/lib/chat/userMessageContent"; + +export function tokenizeUserMessage(...args: Parameters) { + const [text, pastedTextFiles, options] = args; + return tokenizeSharedUserMessage(text, pastedTextFiles, { + ...options, + legacyInlineFileMentions: true, + }); +} diff --git a/crates/agent-mobile/src/agent-ui-adapters/workspacePreview.tsx b/crates/agent-mobile/src/agent-ui-adapters/workspacePreview.tsx new file mode 100644 index 000000000..376c6c34b --- /dev/null +++ b/crates/agent-mobile/src/agent-ui-adapters/workspacePreview.tsx @@ -0,0 +1,10 @@ +export const supportsExternalWorkspaceOpen = false; +export const workspaceOverlayStackClassName = "z-40"; + +export async function readWorkspaceClipboardText() { + return null; +} + +export function WorkspaceOverlayTitleBar() { + return null; +} diff --git a/crates/agent-mobile/src/app/AgentSelector.tsx b/crates/agent-mobile/src/app/AgentSelector.tsx new file mode 100644 index 000000000..65a6c38ca --- /dev/null +++ b/crates/agent-mobile/src/app/AgentSelector.tsx @@ -0,0 +1,171 @@ +import { Check } from "@liveagent/ui/components/IconSet"; +import { DropdownMenuItem, DropdownMenuLabel } from "@liveagent/ui/components/ui/dropdown-menu"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { useEffect, useState } from "react"; +import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; +import type { AgentStatus } from "@/lib/gatewayTypes"; + +function truncateMiddle(value: string, maxLength = 30): string { + if (value.length <= maxLength) return value; + const headLength = Math.ceil((maxLength - 1) / 2); + const tailLength = Math.floor((maxLength - 1) / 2); + return `${value.slice(0, headLength)}…${value.slice(-tailLength)}`; +} + +function agentLabel(agent: AgentStatus): string { + return agent.name?.trim() || agent.agent_id?.trim() || "Agent"; +} + +function sortAgents(agents: AgentStatus[], activeAgent: string): AgentStatus[] { + return [...agents].sort((left, right) => { + const leftID = left.agent_id?.trim() || ""; + const rightID = right.agent_id?.trim() || ""; + if ((leftID === activeAgent) !== (rightID === activeAgent)) { + return leftID === activeAgent ? -1 : 1; + } + if (left.online !== right.online) { + return left.online ? -1 : 1; + } + return agentLabel(left).localeCompare(agentLabel(right)); + }); +} + +// AgentSelector 渲染头像菜单内的 Agent 目录。目录由打标 status 事件与 +// agent_list 响应驱动;单客户端显示身份与状态,多客户端才显示切换列表。 +export function AgentSelector({ + api, + onAgentChange, +}: { + api: GatewayWebSocketClientLike; + onAgentChange?: (agentId: string) => void; +}) { + const { t } = useLocale(); + const [agents, setAgents] = useState([]); + const [activeAgent, setActiveAgent] = useState(() => api.getActiveAgent()); + + useEffect(() => { + const unsubscribe = api.subscribeAgents(setAgents); + // 主动拉一次目录:离线/仅签发凭证的 Agent 不会有 status 事件。 + api + .listAgents() + .then(() => { + const agentId = api.getActiveAgent(); + setActiveAgent(agentId); + onAgentChange?.(agentId); + }) + .catch(() => { + // 目录拉取失败不阻塞页面;status 事件仍会渐进填充在线条目。 + }); + return unsubscribe; + }, [api, onAgentChange]); + + if (agents.length === 0) { + return null; + } + + const handleChange = (agentId: string) => { + setActiveAgent(agentId); + api.setActiveAgent(agentId); + onAgentChange?.(agentId); + }; + + const sortedAgents = sortAgents(agents, activeAgent); + + if (sortedAgents.length === 1) { + const [agent] = sortedAgents; + const agentID = agent.agent_id?.trim() || ""; + const name = agent.name?.trim() || ""; + const statusLabel = agent.online + ? t("settings.devicesOnlineStatus") + : t("settings.devicesOfflineStatus"); + return ( +
+ + {t("settings.devicesTitle")} + +
+
+ + {statusLabel} + + + + {name || truncateMiddle(agentID)} + + + {truncateMiddle(agentID)} + + + + {statusLabel} + +
+
+
+ ); + } + + return ( + <> + + {t("settings.devicesTitle")} + +
+ {sortedAgents.map((agent) => { + const agentID = agent.agent_id?.trim() || ""; + const name = agent.name?.trim() || ""; + const selected = agentID === activeAgent; + const statusLabel = agent.online + ? t("settings.devicesOnlineStatus") + : t("settings.devicesOfflineStatus"); + return ( + handleChange(agentID)} + > + + {statusLabel} + + + + {name || truncateMiddle(agentID)} + + {name ? ( + + {truncateMiddle(agentID)} + + ) : null} + + {selected ? : null} + + ); + })} +
+ + ); +} diff --git a/crates/agent-mobile/src/app/GatewayApp.tsx b/crates/agent-mobile/src/app/GatewayApp.tsx new file mode 100644 index 000000000..bfcdd9095 --- /dev/null +++ b/crates/agent-mobile/src/app/GatewayApp.tsx @@ -0,0 +1,5146 @@ +import { ApplicationView } from "@liveagent/ui/application/ApplicationView"; +import { useApplicationViewState } from "@liveagent/ui/application/useApplicationViewState"; +import { AppErrorBoundary } from "@liveagent/ui/components/AppErrorBoundary"; +import { FileDropOverlay } from "@liveagent/ui/components/chat/FileDropOverlay"; +import type { + MentionComposerDraft, + MentionComposerHandle, +} from "@liveagent/ui/components/chat/MentionComposer"; +import { type NotifyItem, NotifyToast } from "@liveagent/ui/components/chat/NotifyToast"; +import { SharedHistoryManagerModal } from "@liveagent/ui/components/chat/SharedHistoryManagerModal"; +import { TaskProgressBar } from "@liveagent/ui/components/chat/TaskProgressBar"; +import { ToolApprovalBar } from "@liveagent/ui/components/chat/ToolApprovalBar"; +import { WorkspaceResourceSettingsDrawer } from "@liveagent/ui/components/chat/WorkspaceResourceSettingsDrawer"; +import { ChevronDown } from "@liveagent/ui/components/IconSet"; +import { ProjectToolsPanelToggle } from "@liveagent/ui/components/project-tools/ProjectToolsPanelToggle"; +import { RightDockPanel } from "@liveagent/ui/components/project-tools/RightDockPanel"; +import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { ScrollArea } from "@liveagent/ui/components/ui/scroll-area"; +import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/WorkspaceOverlayHost"; +import { LocaleContext, t as translate, useLocaleContextValue } from "@liveagent/ui/i18n/index"; +import { normalizeLogicalLineEndings } from "@liveagent/ui/lib/chat/composerText"; +import { deriveContextUsageTokens } from "@liveagent/ui/lib/chat/contextUsage"; +import { queuedChatTurnHasContent } from "@liveagent/ui/lib/chat/queuedChatTurn"; +import { selectLatestTaskProgress } from "@liveagent/ui/lib/chat/taskProgress"; +import { + readToolApprovalDeadlineAt, + readToolApprovalPending, + readToolApprovalSummary, +} from "@liveagent/ui/lib/chat/toolApprovalArgs"; +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { mergePendingUploadedFiles } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { useChangedFilesActions } from "@liveagent/ui/lib/chat/useChangedFilesActions"; +import { useChatFileLinkNavigation } from "@liveagent/ui/lib/chat/useChatFileLinkNavigation"; +import { + useComposerActions, + useComposerSkillSelection, + useInsertCodeReviewSkill, +} from "@liveagent/ui/lib/chat/useComposerActions"; +import { useRightDockSettings } from "@liveagent/ui/lib/projectTools/useRightDockSettings"; +import { useSettingsOverlay } from "@liveagent/ui/lib/settings/useSettingsOverlay"; +import { createUuid } from "@liveagent/ui/lib/shared/id"; +import { useChatSkills } from "@liveagent/ui/lib/skills/useChatSkills"; +import { terminalSessionBelongsToProject } from "@liveagent/ui/lib/terminal/sessionStore"; +import { useWorkspaceProjectDeletion } from "@liveagent/ui/lib/useWorkspaceProjectRemoval"; +import { useWorkspaceProjectSettingsActions } from "@liveagent/ui/lib/workspaceProjectRemoval"; +import { + ChatComposerBar, + type ChatQueueTurnPreview, + type ContextUsageTokensSource, +} from "@liveagent/ui/pages/chat/ChatComposerBar"; +import { SettingsPage } from "@liveagent/ui/pages/settings/SettingsPage"; +import { + type CSSProperties, + type DragEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { registerAskUserQuestionAnswerHandler } from "@/lib/chat/askUserQuestionBridge"; +import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; +import { buildModelOptions } from "@/lib/chat/chatPageHelpers"; +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import { + adoptHistoryWindowState, + evaluateHistoryWindowResponse, + type HistoryWindowState, + noteHistoryWindowTotal, + planHistoryWindowRequest, + readHistoryWindowCounts, + trimLeadingHeadlessEntries, +} from "@/lib/chat/historyWindow"; +import { isChatRuntimeProtocolIncompatible } from "@/lib/chat/runtimeCompatibility"; +import { createActivityStore } from "@/lib/chat/stream/activityStore"; +import { + type ChatCommandOutcome, + ChatCommandPipeline, + type PendingChatCommand, +} from "@/lib/chat/stream/chatCommandPipeline"; +import { + type ChatCommandUpdate, + type ConversationActivityEvent, + type ConversationStreamEvent, + type ConversationSubscribeResult, + readEventRunId, +} from "@/lib/chat/stream/streamTypes"; +import { + createTranscriptStoreRegistry, + useConversationChat, +} from "@/lib/chat/stream/useConversationChat"; +import { + registerToolApprovalDecisionHandler, + submitToolApprovalDecision, +} from "@/lib/chat/toolApprovalBridge"; +import { + buildOptimisticConversationTitle, + type ChatEntry, + resolveConversationBrowserTitle, +} from "@/lib/chatUi"; +import { getGatewayOrigin } from "@/lib/gatewayOrigin"; +import type { GatewayChatCommandInput } from "@/lib/gatewaySocket"; +import type { + AgentStatus, + ChatEvent, + ChatQueueItemSummary, + ChatQueueSnapshot, + HistoryDetail, + HistoryShareStatus, +} from "@/lib/gatewayTypes"; +import { parseHistoryMessagesJsonAsync } from "@/lib/historyParser"; +import { toModelValue } from "@/lib/providers/llm"; +import { + type ChatRuntimeControls, + DEFAULT_WORKSPACE_PROJECT_ID, + findProviderModelConfig, + getChatRuntimeReasoningLevelsForProvider, + getNextTheme, + isAgentDevMode, + isRightDockSingletonTabOpen, + isThinkingAlwaysOnForModel, + normalizeChatRuntimeControlsForProvider, + openRightDockSingletonTab, + parseSelectedModelJson, + resolveEffectiveTheme, + resolveWorkspaceProjects, + resolveWorkspaceResources, + type SelectedModel, + setSelectedModel, + updateChatRuntimeControlsForProvider, + updateCustomSettings, + updateExecutionModeFromChatSelection, + updateWorkspaceResourceSettings, + type WorkspaceProject, + workspaceProjectPathKey, +} from "@/lib/settings"; +import { createGatewayWorkspaceActivityClient } from "@/lib/workspace-activity/gatewayWorkspaceActivityClient"; +import type { SectionId } from "@/pages/settings/types"; + +const LOCAL_DRAFT_PREFIX = "__local_draft__:"; + +function createLocalDraftConversationId() { + return `${LOCAL_DRAFT_PREFIX}${createUuid()}`; +} +function isLocalDraftConversationId(id: string) { + return id.trim().startsWith(LOCAL_DRAFT_PREFIX); +} + +import { ChangedFilesActionsProvider } from "@liveagent/ui/components/chat/ChangedFilesCard"; +import { HistoryShareModal } from "@liveagent/ui/components/chat/HistoryShareModal"; +import { WorkspaceCloneModal } from "@liveagent/ui/components/chat/WorkspaceCloneModal"; +import { + type WorkspaceCloneTask, + WorkspaceCloneTaskOverlay, +} from "@liveagent/ui/components/chat/WorkspaceCloneTaskOverlay"; +import { buildFloorEntries } from "@liveagent/ui/lib/chat-floor-nav/floorModel"; +import { useScrollFollow } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; +import { + type ConversationOpenState, + createConversationOpenController, +} from "@liveagent/ui/lib/sidebar/openController"; +import { sortSidebarConversations } from "@liveagent/ui/lib/sidebar/reconcile"; +import { createSidebarStore } from "@liveagent/ui/lib/sidebar/store"; +import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; +import { + assignWorkspaceProjectToGroup, + createWorkspaceProjectFromPath, + ensureWorktreeProjectGroup, + fallbackWorkspaceProjectName, + findWorkspaceProject, + getDefaultWorkspaceProjectPath, + mergeWorkspaceProjectsWithHistory, +} from "@liveagent/ui/lib/workspaceProjects"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; +import { FloorNavRail } from "@liveagent/ui/pages/chat/transcript/FloorNavRail"; +import { + CHAT_TRANSCRIPT_WIDTH_CSS_VAR, + TranscriptWidthControls, +} from "@liveagent/ui/pages/chat/transcript/TranscriptWidthControls"; +import { GatewayTranscript, type GatewayTranscriptNavHandle } from "@/components/GatewayTranscript"; +import { parseHistoryShareToken } from "@/lib/historyShare"; +import { + createIdleSidebarBackend, + createWebSidebarBackend, + normalizeGatewayConversationSummary, + normalizeRunningConversationItems, +} from "@/lib/sidebar/webSidebarBackend"; +import { LoginPage } from "@/pages/LoginPage"; +import { SettingsSyncLoading } from "@/pages/SettingsSyncLoading"; +import { SharedHistoryPage } from "@/pages/SharedHistoryPage"; +import { WorkdirPickerModal } from "@/pages/settings/WorkdirPickerModal"; +import { AgentSelector } from "./AgentSelector"; +import { buildTextFromComposerDraft, importPastedTextsAsFiles } from "./chatDraft"; +import { + asErrorMessage, + buildGatewaySelectedModel, + buildGatewaySystemSettings, + isAbortError, + isChatEventTitleFinal, + readChatEventTitle, + readTunnelManagerToolChange, + resolveActiveModelSelection, +} from "./chatEventUtils"; +import { + CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, + CHAT_RUNTIME_KEEP_WARM_INTERVAL_MS, + CHAT_RUNTIME_PREPARE_TIMEOUT_MS, + CHAT_RUNTIME_PREPARING_STATUS, + DEFAULT_BROWSER_TITLE, + HISTORY_DETAIL_INITIAL_MAX_MESSAGES, + HISTORY_DETAIL_LOAD_EARLIER_PAGE_MESSAGES, + HISTORY_LIST_PAGE_SIZE, + MAX_UPLOAD_FILES, + MCP_HUB_BROWSER_TITLE, + NEW_CONVERSATION_BROWSER_TITLE, + PROTECTED_DRAFT_CONVERSATION, + SHARED_HISTORY_BROWSER_TITLE, + SHARED_HISTORY_LIST_PAGE_SIZE, + SKILLS_HUB_BROWSER_TITLE, +} from "./constants"; +import { HistorySwitchLoadingOverlay } from "./HistorySwitchLoadingOverlay"; +import { + formatTranslation, + isMobileSidebarLayout, + resolveVisibleConversationId, + shouldOpenSidebarByDefault, +} from "./historyUtils"; +import { useGatewayClients } from "./hooks/useGatewayClients"; +import { useGatewaySession } from "./hooks/useGatewaySession"; +import { useGatewaySettingsSync } from "./hooks/useGatewaySettingsSync"; +import { usePendingUploads } from "./hooks/usePendingUploads"; +import { useProjectToolsRuntime } from "./hooks/useProjectToolsRuntime"; +import { GatewaySidebarContainer } from "./sidebar/GatewaySidebarContainer"; +import { + type GatewaySidebarStatusFreshnessEvent, + INITIAL_GATEWAY_SIDEBAR_STATUS_FRESHNESS, + reduceGatewaySidebarStatusFreshness, + shouldDisableGatewaySidebarSections, +} from "./sidebar/gatewaySidebarAvailability"; +import type { ModelProviderSource, SendChatFn, SendChatOptions } from "./types"; +import { UserMenu } from "./UserMenu"; + +const STALE_HISTORY_RETRY_INITIAL_DELAY_MS = 1_000; +const STALE_HISTORY_RETRY_MAX_DELAY_MS = 30_000; +const MANUAL_COMPACTION_TIMEOUT_MS = 5 * 60_000; + +type ManualCompactPendingRequest = { + conversationId: string; + operationId: string; + workdir: string; + startedAt: number; +}; + +export default function GatewayApp() { + const historyShareToken = useMemo(() => parseHistoryShareToken(), []); + const { + token, + loginToken, + loginGatewayUrl, + authSubmitting, + authError, + setLoginToken, + setLoginGatewayUrl, + setAuthError, + login: handleLoginSubmit, + clearSession, + } = useGatewaySession(historyShareToken); + const { api, terminalClient, sftpClient, gitClient } = useGatewayClients(token); + const [workspaceCloneTasks, setWorkspaceCloneTasks] = useState([]); + const dismissedWorkspaceCloneTaskIds = useRef(new Set()); + const [activeAgentId, setActiveAgentId] = useState(() => api?.getActiveAgent() ?? ""); + const activeAgentIdRef = useRef(activeAgentId); + const activeAgentScope = activeAgentId || api?.getActiveAgent() || ""; + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + // True only after an authenticated gateway connection has been established + // and then dropped; the initial connect never shows lost-connection UI. + const [gatewayConnectionLost, setGatewayConnectionLost] = useState(false); + // A cached Agent status is usable only after it has been observed on the + // currently authenticated browser-socket epoch. + const [sidebarAgentStatusFresh, setSidebarAgentStatusFresh] = useState(false); + const [conversationId, setConversationId] = useState(""); + // 本地未持久化的会话模型切换(按会话 id 键);发消息随 selected_model + // 落库后由 history-sync 回声在清理 effect 中收敛删除。 + const [conversationModelOverrides, setConversationModelOverrides] = useState< + ReadonlyMap + >(new Map()); + const [chatError, setChatError] = useState(null); + // 用量环手动压缩:以 operationId 关联桌面终态,避免 accepted 被误当作完成。 + // 按会话 id 键化(issue #359 缺陷 #3):不同会话各自独立 pending,一个会话压缩 + // 期间绝不静默屏蔽另一个会话的压缩请求。state 与 ref 经唯一 setter/clearer 同步 + // 写以保证两者一致。 + const [manualCompactPendingByConversation, setManualCompactPendingState] = useState< + ReadonlyMap + >(() => new Map()); + const manualCompactPendingRef = useRef>( + manualCompactPendingByConversation, + ); + const setManualCompactPendingRequest = useCallback((request: ManualCompactPendingRequest) => { + const next = new Map(manualCompactPendingRef.current); + next.set(request.conversationId, request); + manualCompactPendingRef.current = next; + setManualCompactPendingState(next); + }, []); + const clearManualCompactPendingRequest = useCallback( + (conversationId: string, operationId: string) => { + const current = manualCompactPendingRef.current; + const pending = current.get(conversationId); + if (!pending || pending.operationId !== operationId) return false; + const next = new Map(current); + next.delete(conversationId); + manualCompactPendingRef.current = next; + setManualCompactPendingState(next); + return true; + }, + [], + ); + // Top-right toast stack for upload/attachment feedback — mirrors the GUI's + // NotifyToast usage so upload failures never render as conversation output. + const [notifyItems, setNotifyItems] = useState([]); + const notifyIdCounter = useRef(0); + const addNotify = useCallback((type: NotifyItem["type"], message: string) => { + const id = `notify-${++notifyIdCounter.current}`; + setNotifyItems((prev) => [...prev, { id, type, message }]); + }, []); + const dismissNotify = useCallback((id: string) => { + setNotifyItems((prev) => prev.filter((item) => item.id !== id)); + }, []); + // Sidebar errors raised outside the sidebar store (project removal flow). + const [sidebarActionError, setSidebarActionError] = useState(null); + const [queuedChatTurns, setQueuedChatTurns] = useState([]); + const [, setChatQueueRevision] = useState(0); + const [selectedHistoryId, setSelectedHistoryId] = useState(""); + const [selectedHistory, setSelectedHistory] = useState(null); + // Two-phase conversation open (openController): "opening" gates the + // composer/transcript loading affordances; showOverlay drives the switch + // overlay (appears only after ~150ms of still-loading). + const [conversationOpenState, setConversationOpenState] = useState({ + conversationId: "", + phase: "idle", + showOverlay: false, + errorCode: null, + }); + // Explicit "load full history" request from the transcript header. + const [fullHistoryLoading, setFullHistoryLoading] = useState(false); + // Bumped whenever the command pipeline's pending set changes so busy state + // re-derives. + const [pendingCommandRevision, setPendingCommandRevision] = useState(0); + const { + settingsOpen, + overlay, + openSettingsOverlay, + closeSettingsOverlay, + handleSettingsOverlayTransitionEnd, + resetSettingsOverlay, + } = useSettingsOverlay(); + const [projectPickerOpen, setProjectPickerOpen] = useState(false); + const [workspaceCreateModalOpen, setWorkspaceCreateModalOpen] = useState(false); + const [settingsSection, setSettingsSection] = useState("system"); + const [settingsProviderId, setSettingsProviderId] = useState(); + const { settings, setSettings, settingsSyncReady, settingsSyncError, settingsSaveState } = + useGatewaySettingsSync({ token, api, activeAgentId: activeAgentScope }); + const effectiveTheme = resolveEffectiveTheme(settings.theme); + const isAgentMode = settings.system.executionMode !== "text"; + const [activeWorkspaceProjectId, setActiveWorkspaceProjectId] = useState( + () => settings.system.activeWorkspaceProjectId?.trim() || DEFAULT_WORKSPACE_PROJECT_ID, + ); + const missingWorkspaceProjectPathKeys = useMemo( + () => new Set(settings.system.missingWorkspaceProjectPaths.map(workspaceProjectPathKey)), + [settings.system.missingWorkspaceProjectPaths], + ); + const [sidebarOpen, setSidebarOpen] = useState(shouldOpenSidebarByDefault); + const [projectRenamingId, setProjectRenamingId] = useState(null); + const [projectRenameDraft, setProjectRenameDraft] = useState(""); + const [shareConversation, setShareConversation] = useState(null); + const [shareStatus, setShareStatus] = useState(null); + const [shareLoading, setShareLoading] = useState(false); + const [shareUpdating, setShareUpdating] = useState(false); + const [shareError, setShareError] = useState(null); + const [sharedManagerOpen, setSharedManagerOpen] = useState(false); + const [sharedManagerStatuses, setSharedManagerStatuses] = useState< + Record + >({}); + const [sharedManagerLoadingIds, setSharedManagerLoadingIds] = useState>( + () => new Set(), + ); + const [sharedManagerUpdatingIds, setSharedManagerUpdatingIds] = useState>( + () => new Set(), + ); + const [sharedManagerErrors, setSharedManagerErrors] = useState< + Record + >({}); + const [sharedHistoryListError, setSharedHistoryListError] = useState(null); + const [sharedHistoryItems, setSharedHistoryItems] = useState([]); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const { + activeView, + setActiveView, + resourceSettingsProject, + setResourceSettingsProject, + rightDockOpen, + setRightDockOpen, + } = useApplicationViewState(); + const { confirm: requestConfirmDialog, dialog: confirmDialog } = useConfirmDialog(); + // Both elements arrive via callback refs → state so the scroll-follow hook + // re-binds on element identity change and can never keep listeners on a + // dead node. + const [transcriptScrollAreaRoot, setTranscriptScrollAreaRoot] = useState( + null, + ); + const [transcriptViewport, setTranscriptViewport] = useState(null); + const transcriptStageRef = useRef(null); + const { handle: transcriptFollow, following: transcriptFollowing } = useScrollFollow({ + viewport: transcriptViewport, + listenerRoot: transcriptScrollAreaRoot, + trackKeys: true, + }); + // 楼层导航:当前楼层由转写区上报,跳转经 navRef 直达虚拟列表;粘底跟随 + // 激活时程序化滚动会被立即拽回底部——跳转前先按「跳入历史」语义解除跟随。 + const transcriptNavRef = useRef(null); + const [activeFloorKey, setActiveFloorKey] = useState(null); + const handleFloorJump = useCallback( + (rowKey: string) => { + transcriptFollow.breakFollow(); + transcriptNavRef.current?.scrollToRowKey(rowKey); + }, + [transcriptFollow], + ); + const composerRef = useRef(null); + const composerDraftCacheRef = useRef>(new Map()); + const composerDraftOwnerRef = useRef(""); + const conversationIdRef = useRef(conversationId); + const selectedHistoryIdRef = useRef(selectedHistoryId); + const statusRef = useRef(status); + const queuedChatTurnsRef = useRef([]); + const chatQueueConversationIdRef = useRef(""); + const chatQueueRevisionRef = useRef(0); + const queuedChatEditSessionRef = useRef<{ itemId: string; revision: number } | null>(null); + const selectedHistoryRef = useRef(selectedHistory); + const sharedHistoryItemsRef = useRef([]); + const sharedHistoryListRequestRef = useRef<{ + generation: string; + promise: Promise; + } | null>(null); + // Per-conversation runtime workdir (drafts have no persisted summary yet). + const conversationWorkdirsRef = useRef>(new Map()); + // Lazy history windows: per conversation, the persisted-message edge the + // loaded transcript starts at (see lib/chat/historyWindow.ts). Entries are + // (re)established by every applied history fetch and dropped with the + // conversation's other per-id resources. + const historyWindowStatesRef = useRef>(new Map()); + const displayedConversationWorkdirRef = useRef(""); + const pendingUploadContextRef = useRef<{ + conversationId: string; + workdir: string; + executionMode: string; + } | null>(null); + const displayedConversationBusyRef = useRef(false); + const historyLoadSequenceRef = useRef(0); + const visibleConversationRevisionRef = useRef(0); + const previousDisplayedConversationIdRef = useRef(""); + const pendingDisplayedConversationAutoBottomRef = useRef(null); + const protectedConversationRef = useRef(""); + const chatRuntimePreparePromiseRef = useRef | null>(null); + const submitInFlightRef = useRef(false); + // clientRequestId → draft conversation id, until the command binds. + const draftClientRequestsRef = useRef>(new Map()); + const sendChatRef = useRef(null); + const isImportingPastedTextRef = useRef(false); + const resetProjectToolsRuntimeRef = useRef(() => undefined as void); + + // --- Chat streaming infrastructure (Phase 4) ----------------------------- + // Transcript stores (one per conversation), the global activity map, and + // the command pipeline replace the old live-store registry, running-id + // unions, and recovery machinery. + // Ref indirection: the registry memo is stable across token changes while + // the api client is not, and divergence resyncs must reach the live client. + const apiRef = useRef(api); + apiRef.current = api; + const transcriptStoreRegistry = useMemo( + () => + createTranscriptStoreRegistry({ + onDivergence: (divergedConversationId) => + apiRef.current?.resyncConversation(divergedConversationId), + }), + [], + ); + const activityStore = useMemo(() => createActivityStore(), []); + const pipelineOnBoundRef = useRef< + (update: ChatCommandUpdate, pending: PendingChatCommand) => void + >(() => undefined); + const pipelineOnQueuedInGuiRef = useRef< + (update: ChatCommandUpdate, pending: PendingChatCommand) => void + >(() => undefined); + const pipelineOnFailedRef = useRef< + (pending: PendingChatCommand, errorCode: string | null, message: string) => void + >(() => undefined); + const chatCommandPipeline = useMemo( + () => + new ChatCommandPipeline({ + getTranscriptStore: (targetConversationId) => + transcriptStoreRegistry.get(targetConversationId), + onBound: (update, pending) => pipelineOnBoundRef.current(update, pending), + onQueuedInGui: (update, pending) => pipelineOnQueuedInGuiRef.current(update, pending), + onFailed: (pending, errorCode, message) => + pipelineOnFailedRef.current(pending, errorCode, message), + onPendingChanged: () => setPendingCommandRevision((current) => current + 1), + }), + [transcriptStoreRegistry], + ); + + // --- Sidebar state layer -------------------------------------------------- + // One external store owns the whole sidebar domain (list, workdirs, running + // set, per-row mutations); GatewayApp only creates it, feeds it the scope, + // and makes imperative peek/upsertLocal/removeLocal calls. All rendering + // subscriptions live in . + const getSidebarProtectedConversationIds = useCallback(() => { + // Authoritative reconciles keep only these ids when the server list omits + // them: in-flight commands, the protected (displayed) conversation, and + // running conversations. Never a blanket retain-all — that resurrects + // deletions made by other clients while this one was offline. + const ids = new Set(chatCommandPipeline.pendingConversationIds()); + const protectedId = protectedConversationRef.current.trim(); + if (protectedId && protectedId !== PROTECTED_DRAFT_CONVERSATION) { + ids.add(protectedId); + } + for (const id of activityStore.getSnapshot().activities.keys()) { + ids.add(id); + } + return ids; + }, [activityStore, chatCommandPipeline]); + const getActivityKeepConversationIds = useCallback( + () => chatCommandPipeline.pendingConversationIds(), + [chatCommandPipeline], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: Agent ID 是侧边栏 Store 的数据隔离边界。 + const sidebarStore = useMemo( + () => + createSidebarStore( + api + ? createWebSidebarBackend({ + api, + activityStore, + getProtectedConversationIds: getSidebarProtectedConversationIds, + getActivityKeepConversationIds, + }) + : createIdleSidebarBackend(), + { pageSize: HISTORY_LIST_PAGE_SIZE }, + ), + [ + activeAgentScope, + activityStore, + api, + getActivityKeepConversationIds, + getSidebarProtectedConversationIds, + ], + ); + useEffect(() => { + if (!api) { + return; + } + sidebarStore.start(); + return () => { + sidebarStore.stop(); + }; + }, [api, sidebarStore]); + + // Narrow app-root subscriptions: workdirs (rare commits — project merge + // inputs) and the byId index (list commits only; never running/idle ticks). + const sidebarWorkdirs = useSidebarSelector(sidebarStore, (snapshot) => snapshot.workdirs); + const sidebarConversationsById = useSidebarSelector(sidebarStore, (snapshot) => snapshot.byId); + + const workspaceProjects = useMemo( + () => mergeWorkspaceProjectsWithHistory(settings.system, sidebarWorkdirs), + [settings.system, sidebarWorkdirs], + ); + const archivedWorkspaceProjectPathKeys = useMemo( + () => new Set(settings.system.archivedWorkspaceProjectPaths.map(workspaceProjectPathKey)), + [settings.system.archivedWorkspaceProjectPaths], + ); + // Archived workspaces can never be active. Falling back to the full list + // only guards a transient synced state where everything is archived. + const selectableWorkspaceProjects = useMemo(() => { + const active = workspaceProjects.filter( + (project) => !archivedWorkspaceProjectPathKeys.has(workspaceProjectPathKey(project.path)), + ); + return active.length > 0 ? active : workspaceProjects; + }, [archivedWorkspaceProjectPathKeys, workspaceProjects]); + const activeWorkspaceProject = useMemo( + () => findWorkspaceProject(selectableWorkspaceProjects, activeWorkspaceProjectId), + [activeWorkspaceProjectId, selectableWorkspaceProjects], + ); + useEffect(() => { + if (activeWorkspaceProject?.id && activeWorkspaceProject.id !== activeWorkspaceProjectId) { + setActiveWorkspaceProjectId(activeWorkspaceProject.id); + } + }, [activeWorkspaceProject?.id, activeWorkspaceProjectId]); + const activeWorkspaceProjectPath = activeWorkspaceProject?.path.trim() ?? ""; + + // Scope derivation: agent mode with a project → that workdir; agent mode + // without a project → "none" (resolves to an empty list locally, no wire + // sentinel); text mode → unscoped. + useEffect(() => { + sidebarStore.setScope( + isAgentMode + ? activeWorkspaceProjectPath + ? { kind: "workdir", cwd: activeWorkspaceProjectPath } + : { kind: "none" } + : { kind: "unscoped" }, + ); + }, [activeWorkspaceProjectPath, isAgentMode, sidebarStore]); + + // Conversation-open controller: the web end paints the conversation's whole + // established history window in the single open phase — messages above the + // window edge stay unfetched until the user pages up. Deps go through refs + // (assigned per render) so the controller instance stays stable. + const openInitialRef = useRef<(id: string) => Promise<"cache-hit" | "painted">>(() => + Promise.resolve("painted"), + ); + const openController = useMemo( + () => + createConversationOpenController({ + openInitial: (id) => openInitialRef.current(id), + onStateChange: setConversationOpenState, + }), + [], + ); + + const resolveActiveAgentID = useCallback(async () => { + const currentApi = apiRef.current; + if (!currentApi) { + throw new Error("Gateway 尚未连接。"); + } + let agentID = currentApi.getActiveAgent().trim(); + if (!agentID) { + await currentApi.listAgents(); + agentID = currentApi.getActiveAgent().trim(); + } + if (!agentID) { + throw new Error("没有可用的 Agent。"); + } + return agentID; + }, []); + + const { + pendingUploadedFiles, + isUploadingFiles, + isFileDropActive, + fileInputRef, + setUploadingFiles, + getPendingUploadsForConversation, + setPendingUploadsForConversation, + updatePendingUploadsForConversation, + moveConversationUploads, + clearPendingUploads, + handleImportReadableFiles, + handleFileDragEnter, + handleFileDragOver: handlePendingFileDragOver, + handleFileDragLeave, + handleFileDrop: handlePendingFileDrop, + } = usePendingUploads({ + token, + resolveAgentID: resolveActiveAgentID, + historyShareToken, + settingsSyncReady, + settingsOpen, + activeView, + locale: settings.locale, + executionMode: settings.system.executionMode, + conversationId, + selectedHistoryId, + displayedConversationWorkdirRef, + composerRef, + addNotify, + }); + + const applyChatQueueSnapshot = useCallback((snapshot: ChatQueueSnapshot | null | undefined) => { + if (!snapshot) return; + const visibleConversationId = resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ); + if (snapshot.conversationId !== visibleConversationId) { + return; + } + const revision = Number(snapshot.revision ?? 0); + const isSameQueueConversation = snapshot.conversationId === chatQueueConversationIdRef.current; + if (isSameQueueConversation && revision < chatQueueRevisionRef.current) { + return; + } + chatQueueConversationIdRef.current = snapshot.conversationId; + chatQueueRevisionRef.current = revision; + queuedChatTurnsRef.current = snapshot.items.slice(); + setChatQueueRevision(revision); + setQueuedChatTurns(snapshot.items.slice()); + }, []); + + useEffect(() => { + if (!api) return; + return api.subscribeChatQueue((snapshot) => { + applyChatQueueSnapshot(snapshot); + }); + }, [api, applyChatQueueSnapshot]); + + // AskUserQuestion 卡片的应答出口:经网关 chat_queue.tool_answer 送达桌面端 + // 的工具挂起表;桌面端 resolve 后照常以 tool_result 事件流回本端。 + useEffect(() => { + if (!api) { + registerAskUserQuestionAnswerHandler(null); + return; + } + registerAskUserQuestionAnswerHandler(async (toolCallId, answers) => { + const conversationIdValue = getDisplayedConversationId(); + if (!conversationIdValue) { + return { ok: false, message: "No active conversation." }; + } + try { + const response = await api.chatQueueToolAnswer( + conversationIdValue, + toolCallId, + JSON.stringify(answers), + ); + return { ok: response.accepted, message: response.message || undefined }; + } catch (error) { + return { ok: false, message: asErrorMessage(error, "Failed to submit the answer.") }; + } + }); + return () => registerAskUserQuestionAnswerHandler(null); + }, [api]); + + // 工具审批卡片的决定出口:经网关 chat_queue.tool_approval 送达桌面端审批挂起表; + // 桌面端据此放行/拒绝该工具,结果照常以 tool_result 事件流回本端。 + useEffect(() => { + if (!api) { + registerToolApprovalDecisionHandler(null); + return; + } + registerToolApprovalDecisionHandler(async (toolCallId, decision) => { + const conversationIdValue = getDisplayedConversationId(); + if (!conversationIdValue) { + return { ok: false, message: "No active conversation." }; + } + try { + const response = await api.chatQueueToolApproval( + conversationIdValue, + toolCallId, + JSON.stringify({ decision }), + ); + return { ok: response.accepted, message: response.message || undefined }; + } catch (error) { + return { ok: false, message: asErrorMessage(error, "Failed to submit the decision.") }; + } + }); + return () => registerToolApprovalDecisionHandler(null); + }, [api]); + + function getVisibleComposerConversationId() { + return resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current); + } + + function cacheVisibleComposerDraft(conversationId = composerDraftOwnerRef.current) { + const targetConversationId = conversationId.trim(); + const composer = composerRef.current; + if ( + !targetConversationId || + composerDraftOwnerRef.current !== targetConversationId || + !composer + ) { + return; + } + + const draft = composer.getDraft(); + if (draft.isEmpty || !draft.text.trim()) { + composerDraftCacheRef.current.delete(targetConversationId); + return; + } + + composerDraftCacheRef.current.set(targetConversationId, draft); + } + + function prepareComposerForConversationChange() { + cacheVisibleComposerDraft(); + composerDraftOwnerRef.current = ""; + } + + function restoreCachedComposerDraft(conversationId: string) { + const targetConversationId = conversationId.trim(); + const composer = composerRef.current; + if (!targetConversationId || !composer) { + return; + } + + const cachedDraft = composerDraftCacheRef.current.get(targetConversationId); + if (cachedDraft) { + composer.setDraft(cachedDraft); + } else { + composer.clear(); + } + composerDraftOwnerRef.current = targetConversationId; + } + + function clearCachedComposerDraft(conversationId = getVisibleComposerConversationId()) { + const targetConversationId = conversationId.trim(); + if (!targetConversationId) { + return; + } + composerDraftCacheRef.current.delete(targetConversationId); + } + + useEffect(() => { + conversationIdRef.current = conversationId; + }, [conversationId]); + + useEffect(() => { + selectedHistoryIdRef.current = selectedHistoryId; + }, [selectedHistoryId]); + + useEffect(() => { + statusRef.current = status; + }, [status]); + + useEffect(() => { + selectedHistoryRef.current = selectedHistory; + }, [selectedHistory]); + + function getDisplayedConversationId() { + return resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ).trim(); + } + + function isDisplayedConversation(targetConversationId: string) { + const conversationIdValue = targetConversationId.trim(); + return conversationIdValue !== "" && getDisplayedConversationId() === conversationIdValue; + } + + // Sent-prompt history for the composer's ↑/↓ recall. Read lazily from the + // displayed conversation's transcript snapshot at the moment recall starts, + // so transcript growth never re-renders the memoized composer bar. + const loadComposerHistoryPrompts = useCallback(() => { + const store = transcriptStoreRegistry.peek(getDisplayedConversationId()); + if (!store) return []; + const prompts: string[] = []; + for (const row of store.getSnapshot().rows) { + if (row.kind === "user" && row.text.trim()) prompts.push(row.text); + } + return prompts; + }, [transcriptStoreRegistry]); + + const applyLiveConversationTitle = useCallback( + (targetConversationId: string, nextTitle: string) => { + const conversationIdValue = targetConversationId.trim(); + const title = nextTitle.trim(); + if (!conversationIdValue || !title) { + return; + } + + // Position-preserving local upsert: reuse the existing row's updatedAt + // so a live title never reorders the sidebar (the store's own position + // locks cover mutation confirmations). + const now = Date.now(); + const existing = sidebarStore.peek(conversationIdValue); + sidebarStore.upsertLocal({ + id: conversationIdValue, + title, + providerId: existing?.providerId ?? "", + model: existing?.model ?? "", + sessionId: existing?.sessionId, + cwd: existing?.cwd, + messageCount: existing?.messageCount ?? 1, + createdAt: existing?.createdAt ?? now, + updatedAt: existing?.updatedAt ?? now, + isPinned: existing?.isPinned, + pinnedAt: existing ? existing.pinnedAt : null, + isShared: existing?.isShared, + isPending: existing?.isPending, + }); + }, + [sidebarStore], + ); + + // Total entry count of a conversation's transcript store. + const getConversationTranscriptEntryCount = useCallback( + (targetConversationId: string) => { + const store = transcriptStoreRegistry.peek(targetConversationId.trim()); + return store ? store.getSnapshot().entryCount : 0; + }, + [transcriptStoreRegistry], + ); + + const isConversationBusy = useCallback( + (targetConversationId: string) => { + const conversationIdValue = targetConversationId.trim(); + if (!conversationIdValue) { + return false; + } + return ( + activityStore.isRunning(conversationIdValue) || + chatCommandPipeline.hasPending(conversationIdValue) || + transcriptStoreRegistry.peek(conversationIdValue)?.getSnapshot().activeRun != null + ); + }, + [activityStore, chatCommandPipeline, transcriptStoreRegistry], + ); + + // Keep an empty draft conversation's workdir following the active project. + useEffect(() => { + const nextWorkdir = activeWorkspaceProjectPath.trim(); + if (!isAgentMode || !nextWorkdir) { + return; + } + const conversationIdValue = resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ).trim(); + if (!conversationIdValue || !isLocalDraftConversationId(conversationIdValue)) { + return; + } + if (isConversationBusy(conversationIdValue)) { + return; + } + if ( + getConversationTranscriptEntryCount(conversationIdValue) > 0 || + getPendingUploadsForConversation(conversationIdValue).length > 0 + ) { + return; + } + conversationWorkdirsRef.current.set(conversationIdValue, nextWorkdir); + }, [ + activeWorkspaceProjectPath, + getConversationTranscriptEntryCount, + getPendingUploadsForConversation, + isAgentMode, + isConversationBusy, + ]); + + // Quiet history refresh for the displayed conversation: fetch → parse → + // id-preserving merge into the transcript store (no flicker, no remount). + // Only runs while the conversation is idle; a run started mid-fetch aborts + // the merge so a stale snapshot can never truncate freshly folded entries. + // + // Fetches are EDGE-ANCHORED WINDOWS, not full hydrations: the request spans + // from the conversation's established window edge (historyWindow.ts) to the + // tail, so the per-turn refresh cost stays proportional to the loaded + // window instead of the conversation's lifetime size. `extendMessages` + // grows the window upward by a page (the transcript's "load earlier" + // affordance). A windowed response whose top edge slipped below the + // established one (concurrent append while the request was in flight) is + // refetched once with the corrected span and skipped if it slipped again — + // applying it could truncate the rendered region's top. + const refreshDisplayedConversationHistorySnapshot = useCallback( + async ( + targetConversationId: string, + currentApi = api, + options?: { extendMessages?: number }, + ) => { + const conversationIdValue = targetConversationId.trim(); + if (!currentApi || !conversationIdValue || isLocalDraftConversationId(conversationIdValue)) { + return; + } + + const isStillDisplayedAndIdle = () => + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + conversationIdValue && !isConversationBusy(conversationIdValue); + if (!isStillDisplayedAndIdle()) { + return; + } + + const extendMessages = options?.extendMessages; + const windowStates = historyWindowStatesRef.current; + + let detail: HistoryDetail; + let entries: ChatEntry[]; + try { + const planned = planHistoryWindowRequest(windowStates.get(conversationIdValue), { + initialWindowMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES, + extendMessages, + }); + detail = await currentApi.getHistory( + conversationIdValue, + planned === undefined ? undefined : { maxMessages: planned }, + ); + + if (planned !== undefined && detail.has_more === true) { + const counts = readHistoryWindowCounts(detail); + if (!counts) { + // Producer reported a partial window without usable counts (a + // contradiction — both come from the same code path): fall back + // to a full fetch rather than risking a top truncation. + windowStates.delete(conversationIdValue); + detail = await currentApi.getHistory(conversationIdValue); + } else { + const verdict = evaluateHistoryWindowResponse({ + previous: windowStates.get(conversationIdValue), + counts, + extendMessages, + }); + if (verdict.action === "retry") { + detail = await currentApi.getHistory(conversationIdValue, { + maxMessages: verdict.retryMaxMessages, + }); + const retryCounts = readHistoryWindowCounts(detail); + const retryVerdict = + retryCounts && detail.has_more === true + ? evaluateHistoryWindowResponse({ + previous: windowStates.get(conversationIdValue), + counts: retryCounts, + extendMessages, + }) + : null; + if (detail.has_more === true) { + if (!retryVerdict || retryVerdict.action === "retry") { + // The edge slipped twice in a row: give up on this cycle; + // the refresh loops (busy→idle, upsert, stale-retry) + // converge on a later pass. + return; + } + windowStates.set(conversationIdValue, retryVerdict.nextState); + } + } else { + windowStates.set(conversationIdValue, verdict.nextState); + } + } + } + if (detail.has_more !== true) { + // Complete fetch: the window reaches message 0 and stays complete. + const counts = readHistoryWindowCounts(detail); + if (counts) { + windowStates.set(conversationIdValue, { + oldestOffset: 0, + lastTotal: counts.totalMessageCount, + }); + } else { + windowStates.delete(conversationIdValue); + } + } + + const parsed = await parseHistoryMessagesJsonAsync(detail.messages_json); + entries = detail.has_more === true ? trimLeadingHeadlessEntries(parsed) : parsed; + } catch { + return; + } + if (!isStillDisplayedAndIdle()) { + return; + } + const detailConversationId = detail.conversation_id.trim(); + if (detailConversationId !== "" && detailConversationId !== conversationIdValue) { + return; + } + + if (selectedHistoryIdRef.current.trim() === conversationIdValue) { + selectedHistoryRef.current = detail; + setSelectedHistory(detail); + } + transcriptStoreRegistry + .get(conversationIdValue) + .applyHistorySnapshot(entries, { mode: "enrich" }); + }, + [api, isConversationBusy, transcriptStoreRegistry], + ); + + const markVisibleConversationRevision = useCallback(() => { + visibleConversationRevisionRef.current += 1; + return visibleConversationRevisionRef.current; + }, []); + + const invalidateHistoryLoad = useCallback(() => { + historyLoadSequenceRef.current += 1; + return historyLoadSequenceRef.current; + }, []); + + // A draft conversation got its real id (authoritative `command_update + // bound`): re-key every draft-scoped resource onto the real conversation. + const bindDraftConversation = useCallback( + (previousConversationId: string, nextConversationId: string) => { + const previousId = previousConversationId.trim(); + const nextId = nextConversationId.trim(); + if (!previousId || !nextId || previousId === nextId) { + return; + } + + transcriptStoreRegistry.move(previousId, nextId); + + const windowState = historyWindowStatesRef.current.get(previousId); + if (windowState !== undefined) { + historyWindowStatesRef.current.delete(previousId); + historyWindowStatesRef.current.set(nextId, windowState); + } + + const workdir = conversationWorkdirsRef.current.get(previousId); + if (workdir !== undefined) { + conversationWorkdirsRef.current.delete(previousId); + conversationWorkdirsRef.current.set(nextId, workdir); + } + + const cachedComposerDraft = composerDraftCacheRef.current.get(previousId); + if (cachedComposerDraft) { + composerDraftCacheRef.current.delete(previousId); + composerDraftCacheRef.current.set(nextId, cachedComposerDraft); + } + if (composerDraftOwnerRef.current === previousId) { + composerDraftOwnerRef.current = nextId; + } + moveConversationUploads(previousId, nextId); + if (chatQueueConversationIdRef.current === previousId) { + chatQueueConversationIdRef.current = nextId; + } + + if (conversationIdRef.current === previousId) { + conversationIdRef.current = nextId; + setConversationId(nextId); + } + if (selectedHistoryIdRef.current === previousId) { + selectedHistoryIdRef.current = nextId; + setSelectedHistoryId(nextId); + } + if (protectedConversationRef.current.trim() === previousId) { + protectedConversationRef.current = nextId; + } + + // Re-key the sidebar row: drop the draft row, merge its fields under + // the real id. The row stays pending until a server upsert confirms it. + const draftRow = sidebarStore.peek(previousId); + sidebarStore.removeLocal(previousId); + if (draftRow) { + const existingNext = sidebarStore.peek(nextId); + sidebarStore.upsertLocal({ + id: nextId, + title: existingNext?.title?.trim() || draftRow.title, + providerId: existingNext?.providerId || draftRow.providerId, + model: existingNext?.model || draftRow.model, + sessionId: existingNext?.sessionId || draftRow.sessionId, + cwd: existingNext?.cwd || draftRow.cwd, + messageCount: existingNext?.messageCount ?? draftRow.messageCount, + createdAt: existingNext?.createdAt ?? draftRow.createdAt, + updatedAt: existingNext?.updatedAt ?? draftRow.updatedAt, + isPinned: existingNext?.isPinned ?? draftRow.isPinned, + pinnedAt: existingNext ? existingNext.pinnedAt : draftRow.pinnedAt, + isShared: existingNext?.isShared ?? draftRow.isShared, + selectedModelJson: existingNext?.selectedModelJson || draftRow.selectedModelJson, + isPending: existingNext && existingNext.isPending !== true ? undefined : true, + }); + } + // Re-key the local model override so the pick made on the draft keeps + // applying to the bound conversation. + setConversationModelOverrides((prev) => { + const override = prev.get(previousId); + if (!override) return prev; + const next = new Map(prev); + next.delete(previousId); + if (!next.has(nextId)) next.set(nextId, override); + return next; + }); + }, + [moveConversationUploads, sidebarStore, transcriptStoreRegistry], + ); + + const ensureTunnelToolTab = useCallback( + (projectPathKey?: string) => { + const targetProjectPathKey = + workspaceProjectPathKey(projectPathKey) || + workspaceProjectPathKey(activeWorkspaceProjectPath); + if (!targetProjectPathKey) return; + setSettings((prev) => openRightDockSingletonTab(prev, targetProjectPathKey, "tunnel")); + }, + [activeWorkspaceProjectPath, setSettings], + ); + + // Tunnel list refreshes arrive through the tunnel.state push; the chat + // event only opens the tunnel tool tab when the agent creates a tunnel. + const handleTunnelManagerChatEvent = useCallback( + (event: ChatEvent) => { + const change = readTunnelManagerToolChange(event); + if (!change) return; + if (change.action === "create") { + ensureTunnelToolTab(change.projectPathKey); + } + }, + [ensureTunnelToolTab], + ); + + const setWorkspaceProjectDirectoryMissing = useCallback( + (project: WorkspaceProject, missing: boolean) => { + const key = workspaceProjectPathKey(project.path); + const path = project.path.trim(); + if (!key || !path) return; + setSettings((prev) => { + const hasMissingPath = prev.system.missingWorkspaceProjectPaths.some( + (item) => workspaceProjectPathKey(item) === key, + ); + if (hasMissingPath === missing) { + return prev; + } + const missingWorkspaceProjectPaths = missing + ? [...prev.system.missingWorkspaceProjectPaths, path] + : prev.system.missingWorkspaceProjectPaths.filter( + (item) => workspaceProjectPathKey(item) !== key, + ); + return { + ...prev, + system: resolveWorkspaceProjects( + { + ...prev.system, + missingWorkspaceProjectPaths, + }, + getDefaultWorkspaceProjectPath(prev.system), + ), + }; + }); + }, + [setSettings], + ); + + const checkWorkspaceProjectDirectory = useCallback( + async (project: WorkspaceProject, currentApi = api) => { + const path = project.path.trim(); + if (!path) { + setWorkspaceProjectDirectoryMissing(project, true); + return false; + } + if (!currentApi) { + return !missingWorkspaceProjectPathKeys.has(workspaceProjectPathKey(path)); + } + try { + await currentApi.listDirs(path, 1); + setWorkspaceProjectDirectoryMissing(project, false); + return true; + } catch { + setWorkspaceProjectDirectoryMissing(project, true); + return false; + } + }, + [api, missingWorkspaceProjectPathKeys, setWorkspaceProjectDirectoryMissing], + ); + + const activateWorkspaceProject = useCallback( + (project: WorkspaceProject, options?: { startConversation?: boolean }) => { + const pathKey = project.path.trim(); + if (!pathKey) return; + const normalizedPathKey = workspaceProjectPathKey(pathKey); + const matchedProject = workspaceProjects.find( + (item) => + workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, + ); + const targetProject = matchedProject + ? { + ...matchedProject, + ...(project.worktree ? { worktree: project.worktree } : {}), + } + : project; + setActiveWorkspaceProjectId(targetProject.id); + setSettings((prev) => { + const existing = prev.system.workspaceProjects.find( + (item) => + workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, + ); + const nextProject = existing ? { ...targetProject, id: existing.id } : targetProject; + const workspaceProjects = existing + ? prev.system.workspaceProjects.map((item) => + item.id === existing.id + ? { + ...item, + name: item.id === DEFAULT_WORKSPACE_PROJECT_ID ? item.name : nextProject.name, + path: nextProject.path, + kind: + item.id === DEFAULT_WORKSPACE_PROJECT_ID + ? "managed" + : nextProject.kind === "history" + ? item.kind + : nextProject.kind, + worktree: nextProject.worktree ?? item.worktree, + updatedAt: item.updatedAt, + lastConversationAt: + Math.max(item.lastConversationAt ?? 0, nextProject.lastConversationAt ?? 0) || + undefined, + } + : item, + ) + : [...prev.system.workspaceProjects, nextProject]; + const nextSystem = resolveWorkspaceProjects( + { + ...prev.system, + workspaceProjects, + activeWorkspaceProjectId: existing?.id ?? nextProject.id, + hiddenWorkspaceProjectPaths: prev.system.hiddenWorkspaceProjectPaths.filter( + (path) => workspaceProjectPathKey(path) !== normalizedPathKey, + ), + missingWorkspaceProjectPaths: prev.system.missingWorkspaceProjectPaths.filter( + (path) => workspaceProjectPathKey(path) !== normalizedPathKey, + ), + // Activating a workspace always brings it back from the archive. + archivedWorkspaceProjectPaths: prev.system.archivedWorkspaceProjectPaths.filter( + (path) => workspaceProjectPathKey(path) !== normalizedPathKey, + ), + }, + getDefaultWorkspaceProjectPath(prev.system), + ); + return { + ...prev, + system: nextSystem, + }; + }); + if (options?.startConversation) { + setActiveView("chat"); + startNewConversation({ + workdir: targetProject.path, + preserveCurrentComposerDraft: true, + }); + } + }, + [setSettings, workspaceProjects], + ); + + const handleSelectWorkspaceProject = useCallback( + async (project: WorkspaceProject) => { + if (!(await checkWorkspaceProjectDirectory(project))) { + return; + } + activateWorkspaceProject(project); + }, + [activateWorkspaceProject, checkWorkspaceProjectDirectory], + ); + + const handleNewConversationForProject = useCallback( + async (project: WorkspaceProject) => { + if (!(await checkWorkspaceProjectDirectory(project))) { + return; + } + if (isMobileSidebarLayout()) { + setSidebarOpen(false); + } + activateWorkspaceProject(project, { startConversation: true }); + }, + [activateWorkspaceProject, checkWorkspaceProjectDirectory], + ); + + const handleBrowseWorkspaceProjectInFileTree = useCallback( + async (project: WorkspaceProject) => { + if (!(await checkWorkspaceProjectDirectory(project))) { + return; + } + const pathKey = workspaceProjectPathKey(project.path); + if (!pathKey) { + return; + } + + if (isMobileSidebarLayout()) { + setSidebarOpen(false); + } + setActiveView("chat"); + setRightDockOpen(true); + activateWorkspaceProject(project); + setSettings((prev) => openRightDockSingletonTab(prev, pathKey, "fileTree")); + }, + [activateWorkspaceProject, checkWorkspaceProjectDirectory, setSettings], + ); + + const handleOpenCreateWorkspaceProject = useCallback(() => { + setWorkspaceCreateModalOpen(true); + }, []); + + const handleOpenWorkspaceFolder = useCallback(() => { + setWorkspaceCreateModalOpen(false); + setProjectPickerOpen(true); + }, []); + + const handleCloneWorkspaceProject = useCallback( + async (remoteUrl: string, parent: string, name: string, branch: string) => { + if (!api) { + throw new Error("网关未连接。"); + } + const task = await api.gitRequest("clone_start", parent, { + name, + remoteUrl, + branch: branch || undefined, + }); + dismissedWorkspaceCloneTaskIds.current.delete(task.id); + setWorkspaceCloneTasks((tasks) => [task, ...tasks.filter((item) => item.id !== task.id)]); + }, + [api], + ); + + const refreshWorkspaceCloneTasks = useCallback(async () => { + if (!api) return; + const tasks = await api.gitRequest("clone_tasks", ""); + setWorkspaceCloneTasks( + tasks.filter((task) => !dismissedWorkspaceCloneTaskIds.current.has(task.id)), + ); + }, [api]); + + useEffect(() => { + void refreshWorkspaceCloneTasks().catch(() => { + // The next clone operation or connection change retries. + }); + }, [refreshWorkspaceCloneTasks]); + + const hasActiveWorkspaceCloneTask = workspaceCloneTasks.some( + (task) => task.status === "running" || task.status === "cancelling", + ); + + useEffect(() => { + if (!hasActiveWorkspaceCloneTask) return; + const timer = window.setInterval(() => { + void refreshWorkspaceCloneTasks().catch(() => { + // Keep the last task snapshot visible while transport reconnects. + }); + }, 750); + return () => window.clearInterval(timer); + }, [hasActiveWorkspaceCloneTask, refreshWorkspaceCloneTasks]); + + const handleCancelWorkspaceCloneTask = useCallback( + (taskId: string) => { + if (!api) return; + void api + .gitRequest("clone_cancel", "", { taskId }) + .then((task) => + setWorkspaceCloneTasks((tasks) => + tasks.map((item) => (item.id === task.id ? task : item)), + ), + ) + .catch(() => { + // Keep the running task visible so the next poll can reconcile it. + }); + }, + [api], + ); + + const handleDismissWorkspaceCloneTask = useCallback( + (taskId: string) => { + dismissedWorkspaceCloneTaskIds.current.add(taskId); + setWorkspaceCloneTasks((tasks) => tasks.filter((task) => task.id !== taskId)); + if (!api) return; + // 服务端同步移除终态任务,否则刷新页面后快照会让卡片重现。 + void api + .gitRequest("clone_dismiss", "", { taskId }) + .then((tasks) => { + dismissedWorkspaceCloneTaskIds.current.delete(taskId); + setWorkspaceCloneTasks( + tasks.filter((task) => !dismissedWorkspaceCloneTaskIds.current.has(task.id)), + ); + }) + .catch(() => { + // 本地 dismissed 集合已隐藏该卡片;服务端移除失败留待下次快照。 + }); + }, + [api], + ); + + const handleOpenClonedWorkspace = useCallback( + (path: string) => { + activateWorkspaceProject(createWorkspaceProjectFromPath(path, "managed")); + void sidebarStore.refreshWorkdirs("new-workdir"); + }, + [activateWorkspaceProject, sidebarStore], + ); + + const handleLoadWorkspaceRemoteBranches = useCallback( + (remoteUrl: string) => + api?.gitRequest<{ defaultBranch: string; branches: string[] }>("list_remote_branches", "", { + remoteUrl, + }) ?? Promise.reject(new Error("网关未连接。")), + [api], + ); + + const handleWorkdirPickerSelect = useCallback( + (path: string) => { + const normalizedPath = path.trim(); + if (!normalizedPath) return; + activateWorkspaceProject(createWorkspaceProjectFromPath(normalizedPath, "managed")); + void sidebarStore.refreshWorkdirs("new-workdir"); + }, + [activateWorkspaceProject, sidebarStore], + ); + + const handleOpenWorktree = useCallback( + (worktree: { path: string; repositoryPath: string; branch: string }) => { + const path = worktree.path.trim(); + const repositoryPath = worktree.repositoryPath.trim(); + const worktreeKey = workspaceProjectPathKey(path); + const currentProjectPath = displayedConversationWorkdirRef.current.trim(); + if (!path || !repositoryPath || !worktreeKey) return; + const branch = worktree.branch.trim(); + const nextProject: WorkspaceProject = { + ...createWorkspaceProjectFromPath(path, "managed"), + worktree: { + repositoryPath, + ...(branch ? { branch } : {}), + }, + }; + activateWorkspaceProject(nextProject); + setSettings((prev) => { + const sourceProject = prev.system.workspaceProjects.find( + (project) => + workspaceProjectPathKey(project.path) === workspaceProjectPathKey(repositoryPath), + ); + const ensured = ensureWorktreeProjectGroup(prev.system.workspaceProjectGroups, { + name: sourceProject?.name || fallbackWorkspaceProjectName(repositoryPath), + sourceProjectPath: repositoryPath, + }); + let workspaceProjectGroups = assignWorkspaceProjectToGroup( + ensured.groups, + ensured.groupId, + repositoryPath, + ); + if (currentProjectPath) { + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + currentProjectPath, + ); + } + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + path, + ); + return { ...prev, system: { ...prev.system, workspaceProjectGroups } }; + }); + void sidebarStore.refreshWorkdirs("new-workdir"); + }, + [activateWorkspaceProject, setSettings, sidebarStore], + ); + + const updateWorkspaceProjectGroups = useCallback( + (updater: (groups: WorkspaceProjectGroup[]) => WorkspaceProjectGroup[]) => { + setSettings((prev) => { + const next = updater(prev.system.workspaceProjectGroups); + if (next === prev.system.workspaceProjectGroups) return prev; + return { ...prev, system: { ...prev.system, workspaceProjectGroups: next } }; + }); + }, + [setSettings], + ); + + const handleCreateWorkspaceGroup = useCallback( + (nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + const now = Date.now(); + updateWorkspaceProjectGroups((groups) => [ + ...groups, + { id: createUuid(), name, projectPaths: [], createdAt: now, updatedAt: now }, + ]); + }, + [updateWorkspaceProjectGroups], + ); + + const handleRenameWorkspaceGroup = useCallback( + (groupId: string, nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId ? { ...group, name, updatedAt: Date.now() } : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + + const handleDeleteWorkspaceGroup = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => groups.filter((group) => group.id !== groupId)); + }, + [updateWorkspaceProjectGroups], + ); + + const handleMoveWorkspaceProjectToGroup = useCallback( + (projectPath: string, groupId: string | null) => { + const pathKey = workspaceProjectPathKey(projectPath); + if (!pathKey) return; + updateWorkspaceProjectGroups((groups) => { + if (groupId === null) { + return groups.map((group) => { + const projectPaths = group.projectPaths.filter( + (path) => workspaceProjectPathKey(path) !== pathKey, + ); + return projectPaths.length === group.projectPaths.length + ? group + : { ...group, projectPaths, updatedAt: Date.now() }; + }); + } + return assignWorkspaceProjectToGroup(groups, groupId, projectPath); + }); + }, + [updateWorkspaceProjectGroups], + ); + + const handleToggleWorkspaceGroupCollapsed = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId + ? { ...group, collapsed: !group.collapsed, updatedAt: Date.now() } + : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + + const commitWorkspaceProjectRename = useCallback( + (project: WorkspaceProject, nextNameInput: string) => { + if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; + const nextName = nextNameInput.trim(); + if (!nextName || nextName === project.name) return; + setSettings((prev) => { + const pathKey = workspaceProjectPathKey(project.path); + const existing = prev.system.workspaceProjects.find( + (item) => item.id === project.id || workspaceProjectPathKey(item.path) === pathKey, + ); + const updatedProject: WorkspaceProject = { + ...(existing ?? project), + id: existing?.id ?? project.id, + name: nextName, + kind: (existing ?? project).kind === "history" ? "folder" : (existing ?? project).kind, + updatedAt: Date.now(), + }; + const workspaceProjects = existing + ? prev.system.workspaceProjects.map((item) => + item.id === existing.id || workspaceProjectPathKey(item.path) === pathKey + ? updatedProject + : item, + ) + : [...prev.system.workspaceProjects, updatedProject]; + + return { + ...prev, + system: resolveWorkspaceProjects( + { + ...prev.system, + workspaceProjects, + }, + getDefaultWorkspaceProjectPath(prev.system), + ), + }; + }); + }, + [setSettings], + ); + + const handleStartRenamingWorkspaceProject = useCallback((project: WorkspaceProject) => { + if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; + setProjectRenamingId(project.id); + setProjectRenameDraft(project.name); + }, []); + + const handleCommitWorkspaceProjectRename = useCallback(() => { + if (!projectRenamingId) { + return; + } + const project = workspaceProjects.find((item) => item.id === projectRenamingId); + if (project) { + commitWorkspaceProjectRename(project, projectRenameDraft); + } + setProjectRenamingId(null); + setProjectRenameDraft(""); + }, [commitWorkspaceProjectRename, projectRenameDraft, projectRenamingId, workspaceProjects]); + + const handleCancelWorkspaceProjectRename = useCallback(() => { + setProjectRenamingId(null); + setProjectRenameDraft(""); + }, []); + + const handleSetWorkspaceProjectPinned = useCallback( + (project: WorkspaceProject, isPinned: boolean) => { + const pathKey = workspaceProjectPathKey(project.path); + if (!pathKey) return; + + setSettings((prev) => { + const existing = prev.system.workspaceProjects.find( + (item) => item.id === project.id || workspaceProjectPathKey(item.path) === pathKey, + ); + if (!existing && !isPinned) { + return prev; + } + + const now = Date.now(); + const source = existing ?? project; + const updatedProject: WorkspaceProject = { + ...source, + id: existing?.id ?? source.id, + kind: source.id === DEFAULT_WORKSPACE_PROJECT_ID ? "managed" : source.kind, + updatedAt: now, + isPinned, + pinnedAt: isPinned ? now : null, + }; + const workspaceProjects = existing + ? prev.system.workspaceProjects.map((item) => + item.id === existing.id || workspaceProjectPathKey(item.path) === pathKey + ? updatedProject + : item, + ) + : [...prev.system.workspaceProjects, updatedProject]; + + return { + ...prev, + system: resolveWorkspaceProjects( + { + ...prev.system, + workspaceProjects, + }, + getDefaultWorkspaceProjectPath(prev.system), + ), + }; + }); + }, + [setSettings], + ); + + const handleSidebarProjectsCollapsedChange = useCallback( + (projectsCollapsed: boolean) => { + setSettings((prev) => + updateCustomSettings(prev, { + chatSidebar: { + ...prev.customSettings.chatSidebar, + projectsCollapsed, + }, + }), + ); + }, + [setSettings], + ); + + const handleSidebarRecentCollapsedChange = useCallback( + (recentCollapsed: boolean) => { + setSettings((prev) => + updateCustomSettings(prev, { + chatSidebar: { + ...prev.customSettings.chatSidebar, + recentCollapsed, + }, + }), + ); + }, + [setSettings], + ); + + useEffect(() => { + if (!api) { + setGatewayConnectionLost(false); + setSidebarAgentStatusFresh(false); + return; + } + + let wasConnected = false; + let freshnessState = INITIAL_GATEWAY_SIDEBAR_STATUS_FRESHNESS; + const applyFreshnessEvent = (event: GatewaySidebarStatusFreshnessEvent) => { + freshnessState = reduceGatewaySidebarStatusFreshness(freshnessState, event); + setSidebarAgentStatusFresh(freshnessState.agentStatusFresh); + }; + + setGatewayConnectionLost(false); + setSidebarAgentStatusFresh(false); + // Subscribe to connection first so an immediately replayed cached status + // is interpreted against the socket state that owns the current epoch. + const unsubscribeConnection = api.subscribeConnection((connected) => { + applyFreshnessEvent({ type: "connection", connected }); + if (connected) { + wasConnected = true; + setGatewayConnectionLost(false); + } else if (wasConnected) { + setGatewayConnectionLost(true); + } + }); + const unsubscribeStatus = api.subscribeStatus((nextStatus, error) => { + applyFreshnessEvent({ type: "status" }); + statusRef.current = nextStatus; + setStatus(nextStatus); + setStatusError(error); + }); + + return () => { + unsubscribeConnection(); + unsubscribeStatus(); + }; + }, [api]); + + const refreshChatQueueSnapshot = useCallback( + (targetConversationId: string, currentApi = api) => { + const conversationIdValue = targetConversationId.trim(); + if (!currentApi || !conversationIdValue) { + return; + } + void currentApi + .chatQueueGet(conversationIdValue) + .then((response) => applyChatQueueSnapshot(response.snapshot)) + .catch(() => undefined); + }, + [api, applyChatQueueSnapshot], + ); + + // Command pipeline hooks (assigned per render so they see fresh closures). + pipelineOnBoundRef.current = (update, pending) => { + const draftId = draftClientRequestsRef.current.get(pending.clientRequestId)?.trim() ?? ""; + draftClientRequestsRef.current.delete(pending.clientRequestId); + const realId = update.conversationId?.trim() ?? ""; + if (draftId && realId && draftId !== realId) { + bindDraftConversation(draftId, realId); + } + }; + pipelineOnQueuedInGuiRef.current = (update, pending) => { + draftClientRequestsRef.current.delete(pending.clientRequestId); + refreshChatQueueSnapshot(update.conversationId?.trim() || pending.conversationId); + if (pending.isEditResend) { + // The seeded `rebased` already truncated committed optimistically, but + // the command was parked — server-side history is unchanged; a quiet + // window refresh restores the truncated suffix (the edit anchor always + // sits inside the loaded window, so the window covers the whole cut). + void refreshDisplayedConversationHistorySnapshot( + update.conversationId?.trim() || pending.conversationId, + api, + ); + } + }; + pipelineOnFailedRef.current = (pending, _errorCode, message) => { + draftClientRequestsRef.current.delete(pending.clientRequestId); + const conversationIdValue = pending.conversationId.trim(); + if (pending.isEditResend) { + void refreshDisplayedConversationHistorySnapshot(conversationIdValue, api); + } + if (isLocalDraftConversationId(conversationIdValue)) { + // The draft never materialized: drop its optimistic sidebar row. The + // transcript keeps the pipeline's error entry. + sidebarStore.removeLocal(conversationIdValue); + } + if (isDisplayedConversation(conversationIdValue)) { + setChatError(message); + } + }; + + // chat.activity ingestion: the activity store stays the app-wide running + // authority; the sidebar store consumes it through the adapter's diff + // bridge (running/idle events also carry the workdir-activity bumps). + useEffect(() => { + if (!api) { + activityStore.clear(); + return; + } + const unsubscribe = api.subscribeChatActivity((event: ConversationActivityEvent) => { + activityStore.applyActivityEvent(event); + // Settle pending commands from the always-on hub too: the run may + // start (or finish) while its conversation is not the displayed one, + // and without this the 60s startup watchdog would fire spuriously. + // The `queued` state stays armed — the gateway watchdog plus + // command_update failed cover that phase. + if (event.runId && (event.running ? event.state !== "queued" : true)) { + chatCommandPipeline.handleRunSignal( + event.conversationId, + event.runId, + event.clientRequestId ?? undefined, + ); + } + }); + return unsubscribe; + }, [activityStore, api, chatCommandPipeline]); + + useEffect(() => { + if (!api) { + return; + } + return api.subscribeChatCommandUpdates((update) => { + chatCommandPipeline.handleCommandUpdate(update); + }); + }, [api, chatCommandPipeline]); + + // Every (re)connect re-baselines the activity store from the gateway's + // authoritative registry: chat.activity broadcasts are single-shot, so a + // stop that raced a dropped socket would otherwise leave the green dot + // (and streaming cursor fallback) stuck until the next history.list. + useEffect(() => { + if (!api) { + return; + } + let cancelled = false; + const unsubscribe = api.subscribeConnection((connected) => { + if (!connected || cancelled) { + return; + } + void api + .listChatActivities() + .then((items) => { + if (cancelled) { + return; + } + activityStore.hydrate(normalizeRunningConversationItems(items), { + keepConversationIds: chatCommandPipeline.pendingConversationIds(), + }); + }) + .catch(() => undefined); + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [activityStore, api, chatCommandPipeline]); + + const settleManualCompactionResult = useCallback( + ( + targetConversationId: string, + result: { + operationId: string; + status: "compacted" | "failed" | "busy" | "skipped"; + message?: string; + }, + ) => { + const pending = manualCompactPendingRef.current.get(targetConversationId); + if (!pending || pending.operationId !== result.operationId) { + return; + } + if (!clearManualCompactPendingRequest(targetConversationId, result.operationId)) return; + if (result.status === "compacted") { + return; + } + const fallbackKey = + result.status === "skipped" + ? "chat.manualCompactBelowThreshold" + : result.status === "failed" + ? "chat.manualCompactFailed" + : "chat.manualCompactRejected"; + // 缺陷 #4:无论目标会话是否正被显示都提示——用户切走后压缩失败/跳过也要 + // 看到结果。message 本身已说明这是压缩结果,故不再以 isDisplayedConversation 门控。 + setChatError(result.message || translate(fallbackKey, settings.locale)); + }, + [clearManualCompactPendingRequest, settings.locale], + ); + + // App-level observation of the displayed conversation's stream: titles, + // pipeline settlement, queue refreshes, tunnel side effects, and the one + // scroll-compensated fold commit at run_started. + const observeConversationStreamEvent = useCallback( + ( + targetConversationId: string, + event: ConversationStreamEvent, + options?: { replay?: boolean }, + ) => { + const isReplay = options?.replay === true; + const eventClientRequestId = + typeof (event as { client_request_id?: unknown }).client_request_id === "string" + ? ((event as { client_request_id: string }).client_request_id ?? "").trim() + : ""; + switch (event.type) { + case "manual_compaction_result": { + settleManualCompactionResult(targetConversationId, event); + return; + } + case "run_started": { + // The fold this event triggers in the store is a pure data + // transition of the single row list (identical row keys, same DOM + // container, key-addressed measurement cache) — no scroll + // compensation is needed. + chatCommandPipeline.handleRunSignal( + targetConversationId, + readEventRunId(event), + eventClientRequestId || undefined, + ); + return; + } + case "run_finished": { + chatCommandPipeline.handleRunSignal( + targetConversationId, + readEventRunId(event), + eventClientRequestId || undefined, + ); + // Settle the sidebar dot by run identity: the stream's terminal is + // authoritative even when the chat.activity broadcast was missed. + activityStore.settleRun(targetConversationId, readEventRunId(event)); + const finishedTitle = + typeof (event as { title?: unknown }).title === "string" + ? ((event as { title: string }).title ?? "").trim() + : ""; + if (finishedTitle) { + applyLiveConversationTitle(targetConversationId, finishedTitle); + } + return; + } + case "run_queued": { + chatCommandPipeline.handleRunSignal( + targetConversationId, + readEventRunId(event), + eventClientRequestId || undefined, + ); + if (!isReplay) { + refreshChatQueueSnapshot(targetConversationId); + } + return; + } + default: { + const chatEvent = event as ChatEvent; + const liveTitle = readChatEventTitle(chatEvent); + if (liveTitle && isChatEventTitleFinal(chatEvent)) { + applyLiveConversationTitle(targetConversationId, liveTitle); + } + if (!isReplay) { + handleTunnelManagerChatEvent(chatEvent); + } + } + } + }, + [ + activityStore, + applyLiveConversationTitle, + chatCommandPipeline, + handleTunnelManagerChatEvent, + refreshChatQueueSnapshot, + settleManualCompactionResult, + ], + ); + + const handleConversationStreamSync = useCallback( + (targetConversationId: string, result: ConversationSubscribeResult) => { + if (result.activity) { + chatCommandPipeline.handleRunSignal( + targetConversationId, + result.activity.runId, + result.activity.clientRequestId, + ); + } else if (!chatCommandPipeline.hasPending(targetConversationId)) { + // The authoritative subscribe says nothing is running and no local + // submission is in flight: a lingering dot for this conversation is + // a missed-stop zombie — settle it by its own run identity. + const currentActivity = activityStore.get(targetConversationId); + if (currentActivity) { + activityStore.settleRun(targetConversationId, currentActivity.runId); + } + } + for (const event of result.events) { + observeConversationStreamEvent(targetConversationId, event, { replay: true }); + } + }, + [activityStore, chatCommandPipeline, observeConversationStreamEvent], + ); + + const handleConversationStreamEvent = useCallback( + (targetConversationId: string, event: ConversationStreamEvent) => { + observeConversationStreamEvent(targetConversationId, event); + }, + [observeConversationStreamEvent], + ); + + const hasPendingChatCommand = useCallback( + (targetConversationId: string) => chatCommandPipeline.hasPending(targetConversationId), + [chatCommandPipeline], + ); + + // THE transcript source: the displayed conversation's store snapshot plus a + // persistent stream subscription (subscribed whenever the id is real — + // regardless of running state, which is what makes GUI queue auto-sends + // race-free: the next run's events simply flow in). + const displayedConversationId = resolveVisibleConversationId(selectedHistoryId, conversationId); + // composer 的用量环禁点:仅当显示会话自身有 pending(缺陷 #3:不再被其他会话的 + // pending 静默屏蔽)。 + const manualCompactPending = manualCompactPendingByConversation.has(displayedConversationId); + // sidebar 瞬态“转圈”:所有 pending 会话(可多个)——ManualCompactPendingRequest + // 结构上兼容 TransientSidebarRunningConversation(conversationId + workdir)。 + const manualCompactTransientConversations = useMemo( + () => Array.from(manualCompactPendingByConversation.values()), + [manualCompactPendingByConversation], + ); + + // 会话生效模型:本地 override > sidebar 行携带的持久化选择 > 全局默认。 + const selectionForConversation = useCallback( + (targetConversationId: string) => + resolveActiveModelSelection({ + settings, + override: conversationModelOverrides.get(targetConversationId), + persistedSelectedModelJson: sidebarStore.peek(targetConversationId)?.selectedModelJson, + }), + [conversationModelOverrides, settings, sidebarStore], + ); + const activeSelectedModel = useMemo( + () => + resolveActiveModelSelection({ + settings, + override: conversationModelOverrides.get(displayedConversationId), + persistedSelectedModelJson: + sidebarConversationsById.get(displayedConversationId)?.selectedModelJson, + }), + [conversationModelOverrides, displayedConversationId, settings, sidebarConversationsById], + ); + // override 的持久化回声(本会话 selected_model 落库后随 history-sync 回流) + // 到达即清理,会话从此走服务器权威值。 + useEffect(() => { + if (conversationModelOverrides.size === 0) return; + let changed = false; + const next = new Map(conversationModelOverrides); + for (const [id, override] of conversationModelOverrides) { + const persisted = parseSelectedModelJson(sidebarConversationsById.get(id)?.selectedModelJson); + if ( + persisted && + persisted.customProviderId === override.customProviderId && + persisted.model === override.model + ) { + next.delete(id); + changed = true; + } + } + if (changed) setConversationModelOverrides(next); + }, [conversationModelOverrides, sidebarConversationsById]); + + const { transcript: displayedTranscript, busy: displayedConversationBusy } = useConversationChat({ + api, + conversationId: displayedConversationId || null, + registry: transcriptStoreRegistry, + activityStore, + isLocalDraft: isLocalDraftConversationId, + onStreamEvent: handleConversationStreamEvent, + onStreamSync: handleConversationStreamSync, + hasPendingCommand: hasPendingChatCommand, + pendingRevision: pendingCommandRevision, + }); + displayedConversationBusyRef.current = displayedConversationBusy; + + // 后台订阅接线经 ref 稳定化(缺陷 #5):handleConversationStreamEvent/Sync 的 + // useCallback 身份随 locale/pipeline 回调变化,若入 effect 依赖会在 pending 期间 + // 反复退订/重订并重放全量 sync。改用 ref 读取最新实现,effect 只依赖 + // (api, pending 键集, displayedConversationId)。 + const handleConversationStreamEventRef = useRef(handleConversationStreamEvent); + handleConversationStreamEventRef.current = handleConversationStreamEvent; + const handleConversationStreamSyncRef = useRef(handleConversationStreamSync); + handleConversationStreamSyncRef.current = handleConversationStreamSync; + + // store settle:对每个 pending 会话订阅其 store,终态到达即结算(缺陷 #3:按 + // 会话独立,一个 effect 内循环收集 cleanup)。 + useEffect(() => { + if (manualCompactPendingByConversation.size === 0) return; + const cleanups: Array<() => void> = []; + for (const pending of manualCompactPendingByConversation.values()) { + const store = transcriptStoreRegistry.get(pending.conversationId); + const settleFromStore = () => { + const result = store.getSnapshot().manualCompactionResult; + if (result?.operationId === pending.operationId) { + settleManualCompactionResult(pending.conversationId, result); + } + }; + settleFromStore(); + cleanups.push(store.subscribe(settleFromStore)); + } + return () => { + for (const cleanup of cleanups) cleanup(); + }; + }, [manualCompactPendingByConversation, settleManualCompactionResult, transcriptStoreRegistry]); + + // 5 分钟超时:每个 pending 各自计时(缺陷 #4:超时也无论是否显示都提示)。 + useEffect(() => { + if (manualCompactPendingByConversation.size === 0) return; + const timeoutIds: number[] = []; + for (const pending of manualCompactPendingByConversation.values()) { + const remaining = Math.max(0, pending.startedAt + MANUAL_COMPACTION_TIMEOUT_MS - Date.now()); + const timeoutId = window.setTimeout(() => { + if (clearManualCompactPendingRequest(pending.conversationId, pending.operationId)) { + setChatError(translate("chat.manualCompactTimedOut", settings.locale)); + } + }, remaining); + timeoutIds.push(timeoutId); + } + return () => { + for (const id of timeoutIds) window.clearTimeout(id); + }; + }, [clearManualCompactPendingRequest, manualCompactPendingByConversation, settings.locale]); + + // 后台会话订阅:显示会话已由 useConversationChat 订阅,这里补齐其余 pending 会话 + // 的流,使其 store 收到 manual_compaction_result 终态(缺陷 #3 多会话、缺陷 #5 稳定)。 + useEffect(() => { + if (!api || manualCompactPendingByConversation.size === 0) return; + const cleanups: Array<() => void> = []; + for (const pending of manualCompactPendingByConversation.values()) { + if (pending.conversationId === displayedConversationId) continue; + const store = transcriptStoreRegistry.get(pending.conversationId); + const cleanup = api.subscribeConversationStream(pending.conversationId, { + onSync: (result) => { + store.applySync(result); + handleConversationStreamSyncRef.current(pending.conversationId, result); + }, + onEvent: (event) => { + store.applyEvent(event); + handleConversationStreamEventRef.current(pending.conversationId, event); + }, + }); + cleanups.push(cleanup); + } + return () => { + for (const cleanup of cleanups) cleanup(); + }; + }, [api, displayedConversationId, manualCompactPendingByConversation, transcriptStoreRegistry]); + + // Open in flight (history-window fetch, before the replace apply paints). + const historyDetailLoading = conversationOpenState.phase === "opening"; + + // Deterministic messageRef attachment: when the displayed conversation + // transitions busy → idle (run finished), run the quiet enrich refresh so + // the settled tail's user bubbles gain their persisted messageRef (edit + // affordance) without waiting for a history upsert to race the idle gate. + // The upsert-while-idle effect below stays as the backstop for the + // persist-after-done ordering. + const previousDisplayedBusyRef = useRef({ id: "", busy: false }); + useEffect(() => { + const prev = previousDisplayedBusyRef.current; + previousDisplayedBusyRef.current = { + id: displayedConversationId, + busy: displayedConversationBusy, + }; + if ( + prev.id === displayedConversationId && + prev.busy && + !displayedConversationBusy && + displayedConversationId + ) { + void refreshDisplayedConversationHistorySnapshot(displayedConversationId, api); + } + }, [ + api, + displayedConversationBusy, + displayedConversationId, + refreshDisplayedConversationHistorySnapshot, + ]); + + // Inferred liveness failures can settle before the desktop's final history + // write. History upserts are intentionally lossy broadcasts under WebSocket + // backpressure, so a stale displayed turn cannot rely on that one signal: + // keep retrying the quiet enrich at a bounded cadence until history adopts + // the authoritative persisted reply or the conversation becomes busy again. + useEffect(() => { + const conversationIdValue = displayedConversationId.trim(); + if ( + !api || + !conversationIdValue || + displayedConversationBusy || + !displayedTranscript.needsHistoryRefresh + ) { + return; + } + + let cancelled = false; + let timerId: number | null = null; + let retryDelayMs = STALE_HISTORY_RETRY_INITIAL_DELAY_MS; + const retry = async () => { + await refreshDisplayedConversationHistorySnapshot(conversationIdValue, api); + if (cancelled) { + return; + } + const stillStale = + transcriptStoreRegistry.peek(conversationIdValue)?.getSnapshot().needsHistoryRefresh === + true; + if (!stillStale || isConversationBusy(conversationIdValue)) { + return; + } + retryDelayMs = Math.min(retryDelayMs * 2, STALE_HISTORY_RETRY_MAX_DELAY_MS); + timerId = window.setTimeout(() => { + void retry(); + }, retryDelayMs); + }; + + timerId = window.setTimeout(() => { + void retry(); + }, retryDelayMs); + return () => { + cancelled = true; + if (timerId !== null) { + window.clearTimeout(timerId); + } + }; + }, [ + api, + displayedConversationBusy, + displayedConversationId, + displayedTranscript.needsHistoryRefresh, + isConversationBusy, + refreshDisplayedConversationHistorySnapshot, + transcriptStoreRegistry, + ]); + + // The upsert-while-idle backstop: the desktop reports run completion before + // its post-run history flush lands, so the busy→idle enrich above can fetch + // a window that misses the reply. Once the flush lands the desktop publishes + // a history upsert — re-run the quiet enrich for the displayed conversation + // so a turn holding stale or adopted-nothing content converges without a + // re-open. The refresh itself re-checks displayed + idle around the fetch. + // + // The upsert also carries the conversation's authoritative message_count: + // fold it into the window bookkeeping FIRST (for every tracked conversation + // — revisits plan from the same state), so the next planned span covers the + // flushed messages and the post-turn refresh applies in a single request + // instead of tripping the slipped-edge retry. + useEffect(() => { + if (!api) { + return; + } + return api.subscribeHistory((event) => { + if (event.kind !== "upsert") { + return; + } + const conversationIdValue = event.conversation_id.trim(); + if (!conversationIdValue) { + return; + } + const windowStates = historyWindowStatesRef.current; + const windowState = windowStates.get(conversationIdValue); + if (windowState) { + const noted = noteHistoryWindowTotal(windowState, event.conversation.message_count); + if (noted !== windowState) { + windowStates.set(conversationIdValue, noted); + } + } + if ( + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) !== + conversationIdValue + ) { + return; + } + void refreshDisplayedConversationHistorySnapshot(conversationIdValue, api); + }); + }, [api, refreshDisplayedConversationHistorySnapshot]); + + // --- Conversation open (controller deps) ---------------------------------- + // Single-phase open: paint the conversation's established history window + // (first opens fetch the initial tail, revisits everything the user has + // paged up to). Sets the selection state synchronously (controller.open + // calls this in the same tick), fetches the window, and replace-applies it + // to the transcript store. The web end has no synchronous local-activation + // path, so it always resolves "painted" (never "cache-hit") — revisits + // still show the cached transcript instantly because the registry store + // keeps rendering underneath. Messages above the window edge stay + // unfetched until the user pages up ("load earlier history"): hydrating + // the full record on open would put open cost back on the conversation's + // lifetime size, which is exactly what the lazy window avoids. + async function openConversationInitial( + conversationIdValue: string, + ): Promise<"cache-hit" | "painted"> { + const currentApi = api; + if (!currentApi) { + throw new Error("Gateway client is not ready."); + } + + const loadSequence = invalidateHistoryLoad(); + const selectionRevision = markVisibleConversationRevision(); + const isStale = () => + historyLoadSequenceRef.current !== loadSequence || + visibleConversationRevisionRef.current !== selectionRevision; + const previousDisplayedConversationId = getDisplayedConversationId(); + const isChangingConversation = previousDisplayedConversationId !== conversationIdValue; + pendingDisplayedConversationAutoBottomRef.current = conversationIdValue; + if (isChangingConversation && previousDisplayedConversationId) { + // Fold the previous conversation's settled turns so a revisit starts + // with a clean virtualized transcript. + transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTurns(); + } + + protectedConversationRef.current = conversationIdValue; + conversationIdRef.current = conversationIdValue; + selectedHistoryIdRef.current = conversationIdValue; + setConversationId(conversationIdValue); + setSelectedHistoryId(conversationIdValue); + if (isChangingConversation) { + setChatError(null); + setSelectedHistory(null); + } + + try { + // Revisits fetch the conversation's established window (everything the + // user has paged up to), first opens fetch the initial tail window. + // The replace apply repaints the store region from this window either + // way, so the response's counts are adopted unconditionally — on the + // rare in-flight append the region top just lands a page lower. + const planned = planHistoryWindowRequest( + historyWindowStatesRef.current.get(conversationIdValue), + { initialWindowMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES }, + ); + const detail = await currentApi.getHistory( + conversationIdValue, + planned === undefined ? undefined : { maxMessages: planned }, + ); + if (isStale()) { + return "painted"; + } + const counts = readHistoryWindowCounts(detail); + if (counts) { + historyWindowStatesRef.current.set(conversationIdValue, adoptHistoryWindowState(counts)); + } else { + historyWindowStatesRef.current.delete(conversationIdValue); + } + const parsed = await parseHistoryMessagesJsonAsync(detail.messages_json); + const entries = detail.has_more === true ? trimLeadingHeadlessEntries(parsed) : parsed; + if (isStale()) { + return "painted"; + } + setSelectedHistory(detail); + transcriptStoreRegistry + .get(conversationIdValue) + .applyHistorySnapshot(entries, { mode: "replace" }); + const detailWorkdir = detail.conversation?.cwd?.trim(); + if (detailWorkdir) { + conversationWorkdirsRef.current.set(conversationIdValue, detailWorkdir); + } + return "painted"; + } catch (error) { + if (!isStale()) { + const message = asErrorMessage( + error, + translate("chat.history.openFailed", settings.locale), + ); + setSelectedHistory({ + conversation_id: conversationIdValue, + messages_json: message, + has_more: false, + } satisfies HistoryDetail); + setChatError(message); + } + throw error; + } + } + + openInitialRef.current = openConversationInitial; + + const prepareChatRuntime = useCallback( + async ( + reason: string, + currentApi = api, + timeoutMs = CHAT_RUNTIME_PREPARE_TIMEOUT_MS, + ): Promise => { + if (!currentApi) { + throw new Error("Gateway client is not ready."); + } + + if (!chatRuntimePreparePromiseRef.current) { + chatRuntimePreparePromiseRef.current = currentApi + .prepareChatRuntime(reason) + .then((nextStatus) => { + statusRef.current = nextStatus; + setStatus(nextStatus); + setStatusError(null); + if (isChatRuntimeProtocolIncompatible(nextStatus)) { + throw new Error(translate("chat.runtime.protocolIncompatible", settings.locale)); + } + return nextStatus; + }) + .catch((error) => { + setStatusError(asErrorMessage(error, "status request failed")); + throw error; + }) + .finally(() => { + chatRuntimePreparePromiseRef.current = null; + }); + } + + const preparePromise = chatRuntimePreparePromiseRef.current; + if (!preparePromise) { + throw new Error("Gateway chat runtime preparation did not start."); + } + if (timeoutMs <= 0) { + return preparePromise; + } + + let timeoutId: number | null = null; + try { + return await Promise.race([ + preparePromise, + new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new Error("Desktop chat runtime is recovering. Please retry shortly.")); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + } + }, + [api, settings.locale], + ); + + // List loading, reconnect reconciliation, and the 60s silent reconcile all + // live in the sidebar store now (start/refresh/subscribeConnection); the + // old mount/online reload effects are gone with reloadHistory. + + // Foreground nudge: waking the page just pings the runtime keep-warm; the + // socket's own wakeup/reconnect plus per-conversation subscription resume + // replaces the old page-restore recovery machinery. + useEffect(() => { + if (!api || historyShareToken || status?.online !== true) { + return; + } + + const nudgeRuntime = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + void prepareChatRuntime("foreground", api, CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS).catch( + () => undefined, + ); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + nudgeRuntime(); + } + }; + + window.addEventListener("pageshow", nudgeRuntime); + window.addEventListener("focus", nudgeRuntime); + document.addEventListener("visibilitychange", handleVisibilityChange); + document.addEventListener("resume", nudgeRuntime); + nudgeRuntime(); + + return () => { + window.removeEventListener("pageshow", nudgeRuntime); + window.removeEventListener("focus", nudgeRuntime); + document.removeEventListener("visibilitychange", handleVisibilityChange); + document.removeEventListener("resume", nudgeRuntime); + }; + }, [api, historyShareToken, prepareChatRuntime, status?.online]); + + useEffect(() => { + if (!api || historyShareToken || status?.online !== true) { + return; + } + + const keepWarm = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + void prepareChatRuntime("keep-warm", api, CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS).catch( + () => undefined, + ); + }; + + keepWarm(); + const intervalId = window.setInterval(keepWarm, CHAT_RUNTIME_KEEP_WARM_INTERVAL_MS); + return () => { + window.clearInterval(intervalId); + }; + }, [api, historyShareToken, prepareChatRuntime, status?.online]); + + // Lean submission flow: optimistic echo + chat.command through the pipeline. + // Everything after run start flows through the persistent conversation + // stream subscription — sendChat does not consume stream events at all. + async function sendChat( + message: string, + options?: SendChatOptions, + ): Promise { + if (!api) { + return null; + } + + const uploadedFiles = options?.uploadedFiles ?? []; + let activeConversationId = options?.conversationId?.trim() || conversationIdRef.current.trim(); + if (!activeConversationId) { + activeConversationId = createLocalDraftConversationId(); + conversationIdRef.current = activeConversationId; + selectedHistoryIdRef.current = activeConversationId; + setConversationId(activeConversationId); + setSelectedHistoryId(activeConversationId); + } + const startedAsDraftConversation = isLocalDraftConversationId(activeConversationId); + if (chatCommandPipeline.hasPending(activeConversationId)) { + // One in-flight submission per conversation; the composer routes busy + // conversations to the GUI queue instead. + return null; + } + clearCachedComposerDraft(activeConversationId); + + const clientRequestId = options?.clientRequestId?.trim() || createUuid(); + const startedAt = Date.now(); + const persistedConversationWorkdir = sidebarStore.peek(activeConversationId)?.cwd?.trim() || ""; + const runtimeConversationWorkdir = + conversationWorkdirsRef.current.get(activeConversationId)?.trim() || ""; + const effectiveWorkdir = isAgentMode + ? options?.workdir?.trim() || + persistedConversationWorkdir || + runtimeConversationWorkdir || + activeWorkspaceProjectPath || + settings.system.workdir.trim() + : ""; + if (effectiveWorkdir) { + conversationWorkdirsRef.current.set(activeConversationId, effectiveWorkdir); + } + protectedConversationRef.current = activeConversationId; + setChatError(null); + if (isDisplayedConversation(activeConversationId)) { + transcriptFollow.stickToBottom(); + } + const turnSelectedModel = selectionForConversation(activeConversationId); + if (startedAsDraftConversation) { + draftClientRequestsRef.current.set(clientRequestId, activeConversationId); + // Optimistic pending sidebar row: survives authoritative reconciles + // until a server upsert (post-bind) confirms the conversation. + sidebarStore.upsertLocal({ + id: activeConversationId, + title: buildOptimisticConversationTitle(message), + providerId: turnSelectedModel?.customProviderId ?? "", + model: turnSelectedModel?.model ?? "", + cwd: effectiveWorkdir || undefined, + messageCount: 1, + createdAt: startedAt, + updatedAt: startedAt, + isPending: true, + }); + } + + const runtimeControls = normalizeChatRuntimeControlsForProvider( + options?.runtimeControls ?? settings.chatRuntimeControls, + { + providerId: currentChatProvider?.type, + requestFormat: currentChatProvider?.requestFormat, + modelId: turnSelectedModel?.model, + }, + ); + const commandInput: GatewayChatCommandInput = { + type: options?.editMessageRef ? "chat.edit_resend" : "chat.submit", + message, + conversationId: startedAsDraftConversation ? undefined : activeConversationId, + selectedModel: buildGatewaySelectedModel(turnSelectedModel, activeProviders), + systemSettings: buildGatewaySystemSettings(settings, effectiveWorkdir), + uploadedFiles, + clientRequestId, + runtimeControls, + baseMessageRef: options?.editMessageRef, + queuePolicy: options?.queuePolicy ?? "auto", + }; + + const outcome = await chatCommandPipeline.submit({ + conversationId: activeConversationId, + clientRequestId, + message, + attachments: uploadedFiles, + isEditResend: Boolean(options?.editMessageRef), + baseMessageRef: options?.editMessageRef, + optimistic: options?.optimisticEcho !== false, + submit: async () => { + // Preserve the instant optimistic echo, then require the bounded + // runtime wake-up before dispatch. The socket layer still understands + // old gateways; a new gateway must never fall through and dual-send to + // a desktop that lacks reliable chat ingress. + await prepareChatRuntime("send", api, CHAT_RUNTIME_PREPARE_TIMEOUT_MS); + return api.chatCommand(commandInput); + }, + }); + + if (outcome.kind === "accepted") { + const acceptedConversationId = outcome.accepted.conversationId.trim(); + if ( + startedAsDraftConversation && + acceptedConversationId && + acceptedConversationId !== activeConversationId && + !isLocalDraftConversationId(acceptedConversationId) + ) { + // The accept response already carries the real conversation id; run + // the same binding path a `command_update bound` would take. + chatCommandPipeline.handleCommandUpdate({ + runId: outcome.accepted.runId, + clientRequestId, + conversationId: acceptedConversationId, + phase: "bound", + errorCode: null, + message: null, + }); + } + } else if (outcome.kind === "failed") { + draftClientRequestsRef.current.delete(clientRequestId); + } + return outcome; + } + + // Edit-resend is memoized across settings sync; always call the latest sender + // so model and execution-mode overrides stay aligned with the visible WebUI state. + sendChatRef.current = sendChat; + + async function cancelChat(targetConversationId?: string) { + const activeConversationId = targetConversationId?.trim() || getDisplayedConversationId(); + if (!api || !activeConversationId || isLocalDraftConversationId(activeConversationId)) { + return; + } + // No local terminal marking: the stream's run_finished settles the UI + // (cancelling state shows until the agent confirms or the gateway + // watchdog forces the terminal event). + const runId = + transcriptStoreRegistry.peek(activeConversationId)?.getSnapshot().activeRun?.runId ?? + activityStore.get(activeConversationId)?.runId ?? + undefined; + try { + await api.cancelChat(activeConversationId, runId); + } catch (error) { + if (!isAbortError(error)) { + setChatError(asErrorMessage(error, "cancel chat request failed")); + } + } + } + + async function materializeComposerDraftForSend( + draft: MentionComposerDraft, + files: PendingUploadedFile[], + workdir: string, + ) { + let text = normalizeLogicalLineEndings( + isAgentMode && draft.largePastes.length > 0 + ? draft.textWithoutLargePastes + : buildTextFromComposerDraft(draft), + ); + let uploadedFiles = files; + + if (isAgentMode && draft.largePastes.length > 0) { + setChatError(null); + isImportingPastedTextRef.current = true; + setUploadingFiles(true); + try { + const agentID = await resolveActiveAgentID(); + const imported = await importPastedTextsAsFiles({ + token, + agentId: agentID, + workdir, + pastes: draft.largePastes, + }); + if (apiRef.current?.getActiveAgent().trim() !== agentID) { + throw new Error("Agent 已切换,已取消发送本次大段粘贴内容。"); + } + text = buildTextFromComposerDraft(draft, imported.fileByPasteId); + uploadedFiles = mergePendingUploadedFiles(files, imported.files); + } finally { + isImportingPastedTextRef.current = false; + setUploadingFiles(false); + } + } + + return { text, uploadedFiles }; + } + + function clearCurrentComposerDraftForQueuedTurn(conversationId: string) { + const key = conversationId.trim(); + if (!key || getDisplayedConversationId() !== key) { + return; + } + composerRef.current?.clear(); + setPendingUploadsForConversation(key, []); + clearCachedComposerDraft(key); + } + + async function submitCurrentComposerToGuiQueue(queuePolicy: "append" | "interrupt") { + const conversationIdValue = getDisplayedConversationId(); + const draft = composerRef.current?.getDraft() ?? null; + const uploadedFiles = pendingUploadedFiles.slice(); + let clearedComposer = false; + if (!api || !conversationIdValue || !queuedChatTurnHasContent(draft, uploadedFiles)) { + return false; + } + + const workdirForTurn = ( + conversationWorkdirsRef.current.get(conversationIdValue) ?? + displayedConversationWorkdirRef.current ?? + activeWorkspaceProjectPath ?? + settings.system.workdir + ).trim(); + try { + const materialized = await materializeComposerDraftForSend( + draft, + uploadedFiles, + workdirForTurn, + ); + if (!materialized.text && materialized.uploadedFiles.length === 0) { + return false; + } + clearCurrentComposerDraftForQueuedTurn(conversationIdValue); + clearedComposer = true; + if (chatCommandPipeline.hasPending(conversationIdValue)) { + // A command is already in flight for this conversation: park this one + // straight into the GUI queue. The pipeline slot (pre-first-token + // spinner + watchdog) belongs to the first command; the queue panel + // updates via command_update/run_queued and chat_queue events. + await prepareChatRuntime("send", api, CHAT_RUNTIME_PREPARE_TIMEOUT_MS); + await api.chatCommand({ + type: "chat.submit", + message: materialized.text, + conversationId: isLocalDraftConversationId(conversationIdValue) + ? undefined + : conversationIdValue, + selectedModel: buildGatewaySelectedModel( + selectionForConversation(conversationIdValue), + activeProviders, + ), + systemSettings: buildGatewaySystemSettings(settings, workdirForTurn), + uploadedFiles: materialized.uploadedFiles, + clientRequestId: createUuid(), + runtimeControls: chatRuntimeControlsForCurrentProvider, + queuePolicy, + }); + refreshChatQueueSnapshot(conversationIdValue); + return true; + } + // Same pipeline path as a normal send, minus the optimistic transcript + // echo — the prompt is queue-destined and must not flash a bubble. + // `command_update queued_in_gui` (or the stream's run_queued event) + // refreshes the queue snapshot; a direct start settles through + // run_started (whose deferred seeds then render the user message). + const outcome = await sendChat(materialized.text, { + conversationId: conversationIdValue, + uploadedFiles: materialized.uploadedFiles, + runtimeControls: chatRuntimeControlsForCurrentProvider, + workdir: workdirForTurn, + queuePolicy, + optimisticEcho: false, + }); + if (!outcome) { + // Benign no-op (client not ready): restore the composer without + // surfacing an error. + if (getDisplayedConversationId() === conversationIdValue) { + if (!composerRef.current?.hasContent()) { + composerRef.current?.setDraft(draft); + } + if (getPendingUploadsForConversation(conversationIdValue).length === 0) { + setPendingUploadsForConversation(conversationIdValue, uploadedFiles); + } + } + return false; + } + if (outcome.kind === "failed") { + throw new Error(outcome.message); + } + return true; + } catch (error) { + if (clearedComposer && getDisplayedConversationId() === conversationIdValue) { + if (!composerRef.current?.hasContent()) { + composerRef.current?.setDraft(draft); + } + if (getPendingUploadsForConversation(conversationIdValue).length === 0) { + setPendingUploadsForConversation(conversationIdValue, uploadedFiles); + } + } + reportChatQueueActionError(conversationIdValue, error, "queued chat request failed"); + return false; + } + } + + async function commitQueuedChatEdit() { + const session = queuedChatEditSessionRef.current; + const conversationIdValue = getDisplayedConversationId(); + if (!session || !api || !conversationIdValue) return false; + const draft = composerRef.current?.getDraft() ?? null; + const uploadedFiles = pendingUploadedFiles.slice(); + if (!queuedChatTurnHasContent(draft, uploadedFiles)) { + return false; + } + try { + const response = await api.chatQueueEditCommit({ + conversationId: conversationIdValue, + itemId: session.itemId, + revision: session.revision, + draftJson: JSON.stringify(draft), + uploadedFilesJson: JSON.stringify(uploadedFiles), + }); + if (!response.accepted) { + reportChatQueueActionError( + conversationIdValue, + response.message || "queued edit failed", + "queued edit failed", + ); + return false; + } + queuedChatEditSessionRef.current = null; + composerRef.current?.clear(); + setPendingUploadsForConversation(conversationIdValue, []); + clearCachedComposerDraft(conversationIdValue); + applyChatQueueSnapshot(response.snapshot); + return true; + } catch (error) { + reportChatQueueActionError(conversationIdValue, error, "queued edit failed"); + return false; + } + } + + function reportChatQueueActionError(conversationId: string, error: unknown, fallback: string) { + const key = conversationId.trim(); + if (!key) return; + if (isDisplayedConversation(key)) { + setChatError(asErrorMessage(error, fallback)); + } + } + + function runQueuedTurnNow(id: string) { + const conversationIdValue = getDisplayedConversationId(); + if (!api || !conversationIdValue) return; + void api + .chatQueueRunNow(conversationIdValue, id) + .then((response) => { + applyChatQueueSnapshot(response.snapshot); + for (const delayMs of [250, 1000]) { + window.setTimeout(() => { + void api + .chatQueueGet(conversationIdValue) + .then((nextResponse) => applyChatQueueSnapshot(nextResponse.snapshot)) + .catch(() => undefined); + }, delayMs); + } + }) + .catch((error) => { + reportChatQueueActionError(conversationIdValue, error, "queued chat run failed"); + }); + } + + function moveQueuedTurnUp(id: string) { + const conversationIdValue = getDisplayedConversationId(); + if (!api || !conversationIdValue) return; + void api + .chatQueueMove(conversationIdValue, id, "up") + .then((response) => { + applyChatQueueSnapshot(response.snapshot); + }) + .catch((error) => { + reportChatQueueActionError(conversationIdValue, error, "queued chat move failed"); + }); + } + + function editQueuedTurn(id: string) { + const conversationIdValue = getDisplayedConversationId(); + if (!api || !conversationIdValue) return; + void (async () => { + if (queuedChatEditSessionRef.current) { + const committed = await commitQueuedChatEdit(); + if (!committed) return; + } else { + const currentDraft = composerRef.current?.getDraft() ?? null; + const currentUploads = pendingUploadedFiles.slice(); + if (queuedChatTurnHasContent(currentDraft, currentUploads)) { + const queued = await submitCurrentComposerToGuiQueue("append"); + if (!queued) return; + } + } + + const response = await api.chatQueueEditBegin(conversationIdValue, id); + try { + if (!response.accepted || !response.item) { + if (!response.accepted) { + reportChatQueueActionError( + conversationIdValue, + response.message || "queued edit failed", + "queued edit failed", + ); + } + return; + } + const draft = JSON.parse(response.item.draftJson) as MentionComposerDraft; + const uploadedFiles = JSON.parse(response.item.uploadedFilesJson) as PendingUploadedFile[]; + queuedChatEditSessionRef.current = { + itemId: response.item.id, + revision: response.snapshot?.revision ?? chatQueueRevisionRef.current, + }; + composerRef.current?.setDraft(draft); + setPendingUploadsForConversation( + conversationIdValue, + Array.isArray(uploadedFiles) ? uploadedFiles : [], + ); + clearCachedComposerDraft(conversationIdValue); + applyChatQueueSnapshot(response.snapshot); + window.requestAnimationFrame(() => composerRef.current?.focus()); + } catch (error) { + throw new Error(asErrorMessage(error, "invalid queued edit payload")); + } + })().catch((error) => { + reportChatQueueActionError(conversationIdValue, error, "queued chat edit failed"); + }); + } + + function removeQueuedTurn(id: string) { + const conversationIdValue = getDisplayedConversationId(); + if (!api || !conversationIdValue) return; + void api + .chatQueueRemove(conversationIdValue, id) + .then((response) => { + applyChatQueueSnapshot(response.snapshot); + }) + .catch((error) => { + reportChatQueueActionError(conversationIdValue, error, "queued chat remove failed"); + }); + } + + function startNewConversation(options?: { + workdir?: string; + preserveCurrentComposerDraft?: boolean; + }) { + const currentConversationId = getVisibleComposerConversationId().trim(); + if (currentConversationId) { + transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTurns(); + if (options?.preserveCurrentComposerDraft) { + cacheVisibleComposerDraft(currentConversationId); + } else { + clearCachedComposerDraft(currentConversationId); + } + } + invalidateHistoryLoad(); + markVisibleConversationRevision(); + openController.cancel(); + const nextConversationId = createLocalDraftConversationId(); + protectedConversationRef.current = PROTECTED_DRAFT_CONVERSATION; + submitInFlightRef.current = false; + composerDraftOwnerRef.current = ""; + composerRef.current?.clear(); + const nextWorkdir = options?.workdir?.trim() || ""; + if (nextWorkdir) { + conversationWorkdirsRef.current.set(nextConversationId, nextWorkdir); + } + conversationIdRef.current = nextConversationId; + selectedHistoryIdRef.current = nextConversationId; + setConversationId(nextConversationId); + setSelectedHistoryId(nextConversationId); + setChatError(null); + setSelectedHistory(null); + setPendingUploadsForConversation(nextConversationId, []); + } + + const { + removeWorkspaceProjectFromSettings, + handleArchiveWorkspaceProject, + handleUnarchiveWorkspaceProject, + handleWorktreeRemoved, + } = useWorkspaceProjectSettingsActions({ + setSettings, + workspaceProjects, + archivedWorkspaceProjectPathKeys, + activeWorkspaceProject, + activateWorkspaceProject, + setActiveWorkspaceProjectId, + setProjectRenamingId, + setProjectRenameDraft, + }); + + const isWorkspaceProjectRunning = useCallback( + (pathKey: string) => { + if (sidebarStore.getSnapshot().runningWorkdirPathKeys.has(pathKey)) { + return true; + } + for (const [conversationId, activity] of activityStore.getSnapshot().activities) { + const runtimeWorkdir = + activity.workdir?.trim() || + conversationWorkdirsRef.current.get(conversationId)?.trim() || + ""; + const persistedWorkdir = sidebarStore.peek(conversationId)?.cwd?.trim() || ""; + if (workspaceProjectPathKey(runtimeWorkdir || persistedWorkdir) === pathKey) { + return true; + } + } + return false; + }, + [activityStore, sidebarStore], + ); + const pruneWorkspaceProjectTerminalSessions = useCallback((pathKey: string) => { + terminalSessionsVersionRef.current += 1; + setTerminalSessions((current) => + current.filter((session) => !terminalSessionBelongsToProject(session, pathKey)), + ); + }, []); + const closeWorkspaceProjectRightDock = useCallback( + (pathKey: string) => { + if (workspaceProjectPathKey(activeWorkspaceProjectPath) === pathKey) { + setRightDockOpen(false); + } + }, + [activeWorkspaceProjectPath], + ); + const getDisplayedConversationWorkdir = useCallback( + () => displayedConversationWorkdirRef.current, + [], + ); + const refreshWorkspaceProjectWorkdirs = useCallback(() => { + void sidebarStore.refreshWorkdirs("delete"); + }, [sidebarStore]); + const translateWorkspaceProject = useCallback( + (key: string) => translate(key, settings.locale), + [settings.locale], + ); + const handleRemoveWorkspaceProject = useWorkspaceProjectDeletion({ + settings, + t: translateWorkspaceProject, + requestConfirmDialog, + setErrorMessage: setSidebarActionError, + removeWorkspaceProjectFromSettings, + gitClient, + terminalClient, + shouldInspectTerminalSessions: + settings.remote.enableWebTerminal || settings.remote.enableWebSshTerminal, + isWorkspaceProjectRunning, + onPruneTerminalSessions: pruneWorkspaceProjectTerminalSessions, + onCloseRightDockProject: closeWorkspaceProjectRightDock, + getDisplayedConversationWorkdir, + startNewConversation, + onWorktreeRemoved: refreshWorkspaceProjectWorkdirs, + }); + + function handleSidebarNewConversation() { + if (isMobileSidebarLayout()) { + setSidebarOpen(false); + } + setActiveView("chat"); + const visibleConversationId = getVisibleComposerConversationId(); + if ( + activeView !== "chat" && + (visibleConversationId === "" || isLocalDraftConversationId(visibleConversationId)) + ) { + return; + } + startNewConversation({ + workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, + preserveCurrentComposerDraft: true, + }); + } + + function handleSidebarSelectConversation(id: string) { + if (isMobileSidebarLayout()) { + setSidebarOpen(false); + } + setActiveView("chat"); + + const targetConversationId = id.trim(); + if (!targetConversationId) { + return; + } + + const currentConversationId = getVisibleComposerConversationId().trim(); + if (currentConversationId !== targetConversationId) { + prepareComposerForConversationChange(); + } + + pendingDisplayedConversationAutoBottomRef.current = targetConversationId; + + if (isLocalDraftConversationId(targetConversationId)) { + // Local drafts have no server history to load; the transcript store is + // already the source (optimistic entries and error entries included). + openController.cancel(); + invalidateHistoryLoad(); + markVisibleConversationRevision(); + if (currentConversationId && currentConversationId !== targetConversationId) { + transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTurns(); + } + protectedConversationRef.current = targetConversationId; + conversationIdRef.current = targetConversationId; + selectedHistoryIdRef.current = targetConversationId; + setConversationId(targetConversationId); + setSelectedHistoryId(targetConversationId); + setChatError(null); + setSelectedHistory(null); + restoreCachedComposerDraft(targetConversationId); + return; + } + + // Paint the conversation's established history window; the overlay + // appears only after ~150ms of still-loading. + openController.open(targetConversationId); + restoreCachedComposerDraft(targetConversationId); + } + + // Conversations that left the authoritative sidebar index (remote deletes, + // confirmed local deletes, reconcile drops): clean per-conversation caches + // and migrate the selection when the displayed conversation vanished. + // Local drafts are skipped — a failed draft keeps its transcript (error + // entry) visible; user-initiated draft removal goes through + // handleSidebarLocalDraftDeleted instead. + const handleSidebarConversationsRemoved = useCallback( + (ids: readonly string[]) => { + const displayedId = getDisplayedConversationId(); + let displayedRemoved = false; + const removedIds = new Set(); + for (const id of ids) { + if (isLocalDraftConversationId(id)) { + continue; + } + removedIds.add(id); + transcriptStoreRegistry.remove(id); + historyWindowStatesRef.current.delete(id); + conversationWorkdirsRef.current.delete(id); + composerDraftCacheRef.current.delete(id); + setPendingUploadsForConversation(id, []); + if (id === displayedId) { + displayedRemoved = true; + } + } + if (removedIds.size === 0) { + return; + } + const nextSharedItems = sharedHistoryItemsRef.current.filter( + (item) => !removedIds.has(item.id), + ); + if (nextSharedItems.length !== sharedHistoryItemsRef.current.length) { + sharedHistoryItemsRef.current = nextSharedItems; + setSharedHistoryItems(nextSharedItems); + } + if (displayedRemoved) { + startNewConversation({ + workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, + }); + } + }, + [ + activeWorkspaceProjectPath, + isAgentMode, + setPendingUploadsForConversation, + transcriptStoreRegistry, + ], + ); + + const handleSidebarLocalDraftDeleted = useCallback( + (id: string) => { + transcriptStoreRegistry.remove(id); + historyWindowStatesRef.current.delete(id); + conversationWorkdirsRef.current.delete(id); + composerDraftCacheRef.current.delete(id); + setPendingUploadsForConversation(id, []); + if (conversationIdRef.current === id || selectedHistoryIdRef.current === id) { + startNewConversation({ + workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, + }); + } + }, + [ + activeWorkspaceProjectPath, + isAgentMode, + setPendingUploadsForConversation, + transcriptStoreRegistry, + ], + ); + + function handleSidebarOpenSkillsHub() { + setRightDockOpen(false); + if (isMobileSidebarLayout()) { + setSidebarOpen(false); + } + cacheVisibleComposerDraft(); + setActiveView("skills-hub"); + } + + function handleSidebarOpenMcpHub() { + setRightDockOpen(false); + if (isMobileSidebarLayout()) { + setSidebarOpen(false); + } + cacheVisibleComposerDraft(); + setActiveView("mcp-hub"); + } + + function handleOpenShareModal(item: ChatHistorySummary) { + setShareConversation(item); + setShareStatus(null); + setShareError(null); + if (!api) { + setShareError("Gateway 尚未连接,无法读取分享状态。"); + return; + } + + setShareLoading(true); + void api + .getHistoryShare(item.id) + .then((status) => { + setShareStatus(status); + setSharedManagerStatuses((current) => ({ ...current, [item.id]: status })); + markSharedConversation(item.id, status.enabled === true, item); + }) + .catch((error) => { + setShareError(asErrorMessage(error, "读取分享状态失败")); + }) + .finally(() => { + setShareLoading(false); + }); + } + + function handleCloseShareModal() { + setShareConversation(null); + setShareStatus(null); + setShareError(null); + setShareLoading(false); + setShareUpdating(false); + } + + function handleToggleHistoryShare(enabled: boolean, options?: { redactToolContent?: boolean }) { + const item = shareConversation; + if (!api || !item) { + return; + } + + setShareError(null); + setShareUpdating(true); + void api + .setHistoryShare(item.id, enabled, options) + .then((status) => { + setShareStatus(status); + setSharedManagerStatuses((current) => ({ ...current, [item.id]: status })); + markSharedConversation(item.id, status.enabled === true, item); + }) + .catch((error) => { + setShareError(asErrorMessage(error, enabled ? "开启分享失败" : "关闭分享失败")); + }) + .finally(() => { + setShareUpdating(false); + }); + } + + function handleSetShareRedactToolContent(redactToolContent: boolean) { + const item = shareConversation; + if (!api || !item) { + return; + } + + setShareError(null); + setShareUpdating(true); + void api + .setHistoryShare(item.id, true, { redactToolContent }) + .then((status) => { + setShareStatus(status); + setSharedManagerStatuses((current) => ({ ...current, [item.id]: status })); + markSharedConversation(item.id, status.enabled === true, item); + }) + .catch((error) => { + setShareError(asErrorMessage(error, "更新分享脱敏设置失败")); + }) + .finally(() => { + setShareUpdating(false); + }); + } + + function updateSharedManagerIdSet( + setter: (updater: (current: ReadonlySet) => ReadonlySet) => void, + id: string, + enabled: boolean, + ) { + setter((current) => { + const next = new Set(current); + if (enabled) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + } + + function setSharedManagerError(id: string, message: string | null) { + setSharedManagerErrors((current) => { + const next = { ...current }; + if (message) { + next[id] = message; + } else { + delete next[id]; + } + return next; + }); + } + + const setSharedHistoryItemsState = useCallback((items: ChatHistorySummary[]) => { + const nextItems = sortSidebarConversations(items.map((item) => ({ ...item, isShared: true }))); + sharedHistoryItemsRef.current = nextItems; + setSharedHistoryItems(nextItems); + }, []); + + const refreshSharedHistoryItems = useCallback( + async (currentApi = api, options?: { force?: boolean; generation?: string }) => { + if (!currentApi) { + sharedHistoryListRequestRef.current = null; + setSharedHistoryItemsState([]); + setSharedHistoryListError(null); + return []; + } + if (sharedHistoryListRequestRef.current && options?.force !== true) { + return sharedHistoryListRequestRef.current.promise; + } + + const request = (async () => { + const byId = new Map(); + let totalCount = 0; + for (let pageNumber = 1; ; pageNumber += 1) { + const response = await currentApi.listSharedHistory( + pageNumber, + SHARED_HISTORY_LIST_PAGE_SIZE, + ); + totalCount = Math.max(0, response.total_count); + for (const conversation of response.conversations) { + const item = normalizeGatewayConversationSummary(conversation); + byId.set(item.id, { ...item, isShared: true }); + } + if (response.conversations.length === 0 || byId.size >= totalCount) { + break; + } + } + + return sortSidebarConversations(Array.from(byId.values())); + })(); + + const requestState = { + generation: options?.generation?.trim() || "manual", + promise: request, + }; + sharedHistoryListRequestRef.current = requestState; + setSharedHistoryListError(null); + try { + const nextItems = await request; + if (sharedHistoryListRequestRef.current === requestState) { + setSharedHistoryItemsState(nextItems); + setSharedHistoryListError(null); + return nextItems; + } + return sharedHistoryItemsRef.current; + } catch (error) { + if (sharedHistoryListRequestRef.current === requestState) { + setSharedHistoryListError(asErrorMessage(error, "读取已分享历史列表失败")); + } + return sharedHistoryItemsRef.current; + } finally { + if (sharedHistoryListRequestRef.current === requestState) { + sharedHistoryListRequestRef.current = null; + } + } + }, + [api, setSharedHistoryItemsState], + ); + + useEffect(() => { + if (!api) { + sharedHistoryListRequestRef.current = null; + setSharedHistoryItemsState([]); + setSharedHistoryListError(null); + return; + } + if (gatewayConnectionLost || status?.online !== true) { + return; + } + // Force a new generation when the browser socket recovers or the desktop + // AgentSession identity changes. A request tied to the old generation may + // finish with `agent offline`; it must not poison the freshly loaded list. + void refreshSharedHistoryItems(api, { + force: true, + generation: status.session_id?.trim() || "online", + }); + }, [ + api, + gatewayConnectionLost, + refreshSharedHistoryItems, + setSharedHistoryItemsState, + status?.online, + status?.session_id, + ]); + + function markSharedConversation( + id: string, + isShared: boolean, + source?: ChatHistorySummary | null, + ) { + const existingRow = sidebarStore.peek(id); + if (existingRow && existingRow.isShared !== isShared) { + sidebarStore.upsertLocal({ ...existingRow, isShared }); + } + if (!isShared) { + setSharedHistoryItemsState(sharedHistoryItemsRef.current.filter((item) => item.id !== id)); + return; + } + + const conversation = + source ?? existingRow ?? sharedHistoryItemsRef.current.find((item) => item.id === id); + if (!conversation) { + return; + } + setSharedHistoryItemsState([ + { ...conversation, isShared: true }, + ...sharedHistoryItemsRef.current.filter((item) => item.id !== id), + ]); + } + + function handleLoadSharedHistoryStatus(item: ChatHistorySummary) { + const id = item.id.trim(); + if (!id) { + return; + } + if (!api) { + setSharedManagerError(id, "Gateway 尚未连接,无法读取分享状态。"); + return; + } + + setSharedManagerError(id, null); + updateSharedManagerIdSet(setSharedManagerLoadingIds, id, true); + void api + .getHistoryShare(id) + .then((status) => { + setSharedManagerStatuses((current) => ({ ...current, [id]: status })); + markSharedConversation(id, status.enabled === true, item); + }) + .catch((error) => { + setSharedManagerError(id, asErrorMessage(error, "读取分享状态失败")); + }) + .finally(() => { + updateSharedManagerIdSet(setSharedManagerLoadingIds, id, false); + }); + } + + function handleRefreshSharedHistoryStatuses() { + void refreshSharedHistoryItems().then((items) => { + items.forEach(handleLoadSharedHistoryStatus); + }); + } + + function handleOpenSharedHistoryManager() { + setSharedManagerOpen(true); + void refreshSharedHistoryItems().then((items) => { + items.forEach(handleLoadSharedHistoryStatus); + }); + } + + function handleDisableSharedHistory(item: ChatHistorySummary) { + const id = item.id.trim(); + if (!id) { + return; + } + if (!api) { + setSharedManagerError(id, "Gateway 尚未连接,无法关闭分享。"); + return; + } + + setSharedManagerError(id, null); + updateSharedManagerIdSet(setSharedManagerUpdatingIds, id, true); + void api + .setHistoryShare(id, false) + .then((status) => { + setSharedManagerStatuses((current) => ({ ...current, [id]: status })); + markSharedConversation(id, status.enabled === true, item); + if (shareConversation?.id === id) { + setShareStatus(status); + } + }) + .catch((error) => { + setSharedManagerError(id, asErrorMessage(error, "关闭分享失败")); + }) + .finally(() => { + updateSharedManagerIdSet(setSharedManagerUpdatingIds, id, false); + }); + } + + function handleSetSharedHistoryRedactToolContent( + item: ChatHistorySummary, + redactToolContent: boolean, + ) { + const id = item.id.trim(); + if (!id) { + return; + } + if (!api) { + setSharedManagerError(id, "Gateway 尚未连接,无法更新分享脱敏设置。"); + return; + } + + setSharedManagerError(id, null); + updateSharedManagerIdSet(setSharedManagerUpdatingIds, id, true); + void api + .setHistoryShare(id, true, { redactToolContent }) + .then((status) => { + setSharedManagerStatuses((current) => ({ ...current, [id]: status })); + markSharedConversation(id, status.enabled === true, item); + if (shareConversation?.id === id) { + setShareStatus(status); + } + }) + .catch((error) => { + setSharedManagerError(id, asErrorMessage(error, "更新分享脱敏设置失败")); + }) + .finally(() => { + updateSharedManagerIdSet(setSharedManagerUpdatingIds, id, false); + }); + } + + const handleResendFromEdit = useCallback( + async (messageRef: HistoryMessageRef, text: string, uploadedFiles: PendingUploadedFile[]) => { + const activeConversationId = conversationIdRef.current.trim(); + if ( + !api || + !activeConversationId || + isLocalDraftConversationId(activeConversationId) || + isConversationBusy(activeConversationId) + ) { + return; + } + const normalized = text.trim(); + if (!normalized && uploadedFiles.length === 0) { + return; + } + + setChatError(null); + composerRef.current?.clear(); + setPendingUploadsForConversation(activeConversationId, []); + + // Same pipeline path as a normal send, carrying the base message ref. + // The pipeline atomically truncates the visible suffix and inserts the + // optimistic replacement; the stream's seeded `rebased` event confirms + // it, and `user_message` adopts the bubble by client_request_id. + try { + await sendChatRef.current?.(normalized, { + conversationId: activeConversationId, + uploadedFiles, + editMessageRef: messageRef, + }); + } catch (error) { + setChatError(asErrorMessage(error, "编辑后重发失败")); + } + }, + [api, isConversationBusy, setPendingUploadsForConversation], + ); + + // handleSidebarSelectConversation is a plain per-render function; route the + // branch handler through a ref (same pattern as sendChatRef) so the callback + // stays stable for the memoized transcript region. + const selectConversationRef = useRef(handleSidebarSelectConversation); + selectConversationRef.current = handleSidebarSelectConversation; + // The button stays clickable during the WS round-trip; a ref (not state) + // blocks duplicate confirms without changing the callback identity, while + // the pending-anchor state drives the clicked row's spinner. + const branchInFlightRef = useRef(false); + const [branchPendingMessageId, setBranchPendingMessageId] = useState(null); + + const handleBranchConversation = useCallback( + async (messageRef: HistoryMessageRef) => { + if (!api) return; + const activeConversationId = conversationIdRef.current.trim(); + if ( + !activeConversationId || + isLocalDraftConversationId(activeConversationId) || + isConversationBusy(activeConversationId) + ) { + return; + } + if (branchInFlightRef.current) return; + branchInFlightRef.current = true; + setBranchPendingMessageId(messageRef.messageId); + try { + const summary = await api.branchHistory(activeConversationId, messageRef); + sidebarStore.upsertLocal(normalizeGatewayConversationSummary(summary)); + selectConversationRef.current(summary.id); + } catch (error) { + setChatError(asErrorMessage(error, translate("chat.branchFailed", settings.locale))); + } finally { + branchInFlightRef.current = false; + setBranchPendingMessageId(null); + } + }, + [api, isConversationBusy, sidebarStore, settings.locale], + ); + + const handleLoadUploadedImagePreview = useCallback( + async (workspaceRoot: string, absolutePath: string) => { + if (!api) { + return null; + } + const result = await api.readUploadedImagePreview(workspaceRoot, absolutePath); + if (!result.data.trim()) { + return null; + } + return result; + }, + [api], + ); + + const handleComposerBusyChange = useCallback((_isBusy: boolean) => {}, []); + + function openSettings(section: SectionId = "system", providerId?: string) { + if (isMobileSidebarLayout()) { + setSidebarOpen(false); + } + setSettingsSection(section); + setSettingsProviderId(section === "providers" ? providerId : undefined); + openSettingsOverlay(); + } + + function closeSettings() { + closeSettingsOverlay(); + } + + function handleSettingsTransitionEnd() { + handleSettingsOverlayTransitionEnd(); + } + + const handleLogout = useCallback(() => { + invalidateHistoryLoad(); + markVisibleConversationRevision(); + // Dropping the api swaps in a fresh (empty) sidebar store; the start/stop + // effect stops the old one. + openController.cancel(); + clearSession(); + chatCommandPipeline.reset(); + transcriptStoreRegistry.clear(); + activityStore.clear(); + draftClientRequestsRef.current.clear(); + historyWindowStatesRef.current.clear(); + conversationWorkdirsRef.current.clear(); + composerDraftCacheRef.current.clear(); + composerDraftOwnerRef.current = ""; + composerRef.current?.clear(); + conversationIdRef.current = ""; + selectedHistoryIdRef.current = ""; + selectedHistoryRef.current = null; + sharedHistoryItemsRef.current = []; + sharedHistoryListRequestRef.current = null; + clearPendingUploads(); + protectedConversationRef.current = ""; + submitInFlightRef.current = false; + setUserMenuOpen(false); + resetSettingsOverlay(); + setStatus(null); + setStatusError(null); + setConversationId(""); + setChatError(null); + setSidebarActionError(null); + setSharedHistoryListError(null); + setFullHistoryLoading(false); + setSharedHistoryItems([]); + queuedChatTurnsRef.current = []; + chatQueueConversationIdRef.current = ""; + chatQueueRevisionRef.current = 0; + queuedChatEditSessionRef.current = null; + setQueuedChatTurns([]); + setChatQueueRevision(0); + resetProjectToolsRuntimeRef.current(); + setSelectedHistoryId(""); + setSelectedHistory(null); + }, [ + activityStore, + chatCommandPipeline, + clearPendingUploads, + clearSession, + invalidateHistoryLoad, + markVisibleConversationRevision, + openController, + transcriptStoreRegistry, + ]); + + const userMenuLabel = (status?.name || status?.agent_id || "当前用户").trim() || "当前用户"; + const userAvatarLabel = userMenuLabel.slice(0, 1).toUpperCase(); + const handleActiveAgentChange = useCallback( + (agentId: string) => { + const nextAgentId = agentId.trim(); + const previousAgentId = activeAgentIdRef.current.trim(); + activeAgentIdRef.current = nextAgentId; + setActiveAgentId(nextAgentId); + setStatusError(null); + setChatError(null); + setSidebarActionError(null); + if (!previousAgentId || previousAgentId === nextAgentId) { + return; + } + + invalidateHistoryLoad(); + markVisibleConversationRevision(); + openController.cancel(); + chatCommandPipeline.reset(); + transcriptStoreRegistry.clear(); + activityStore.clear(); + draftClientRequestsRef.current.clear(); + conversationWorkdirsRef.current.clear(); + composerDraftCacheRef.current.clear(); + composerDraftOwnerRef.current = ""; + composerRef.current?.clear(); + clearPendingUploads(); + pendingUploadContextRef.current = null; + protectedConversationRef.current = ""; + submitInFlightRef.current = false; + conversationIdRef.current = ""; + selectedHistoryIdRef.current = ""; + selectedHistoryRef.current = null; + previousDisplayedConversationIdRef.current = ""; + pendingDisplayedConversationAutoBottomRef.current = null; + displayedConversationWorkdirRef.current = ""; + sharedHistoryItemsRef.current = []; + sharedHistoryListRequestRef.current = null; + queuedChatTurnsRef.current = []; + chatQueueConversationIdRef.current = ""; + chatQueueRevisionRef.current = 0; + queuedChatEditSessionRef.current = null; + statusRef.current = null; + setStatus(null); + setSidebarAgentStatusFresh(false); + setGatewayConnectionLost(false); + setConversationId(""); + setSelectedHistoryId(""); + setSelectedHistory(null); + setConversationModelOverrides(new Map()); + setFullHistoryLoading(false); + setQueuedChatTurns([]); + setChatQueueRevision(0); + setSharedHistoryItems([]); + setSharedHistoryListError(null); + setShareConversation(null); + setShareStatus(null); + setShareLoading(false); + setShareUpdating(false); + setShareError(null); + setSharedManagerOpen(false); + setSharedManagerStatuses({}); + setSharedManagerLoadingIds(new Set()); + setSharedManagerUpdatingIds(new Set()); + setSharedManagerErrors({}); + setProjectPickerOpen(false); + resetSettingsOverlay(); + setActiveView("chat"); + setRightDockOpen(false); + setNotifyItems([]); + resetProjectToolsRuntimeRef.current(); + }, + [ + activityStore, + chatCommandPipeline, + clearPendingUploads, + invalidateHistoryLoad, + markVisibleConversationRevision, + openController, + transcriptStoreRegistry, + ], + ); + + const localeContextValue = useLocaleContextValue(settings.locale); + + const activeProviders = useMemo( + // WebUI provider config should follow the synced settings payload directly. + // Using a separately fetched provider summary here can leave the model list stale + // after Settings has already synced in either direction. + () => settings.customProviders, + [settings.customProviders], + ); + + const currentModelLabel = useMemo(() => { + if (!activeSelectedModel) { + return "选择模型"; + } + const provider = activeProviders.find( + (item) => item.id === activeSelectedModel.customProviderId, + ); + return provider ? `${provider.name} / ${activeSelectedModel.model}` : activeSelectedModel.model; + }, [activeProviders, activeSelectedModel]); + const currentModelContextWindow = useMemo(() => { + if (!activeSelectedModel) { + return undefined; + } + const provider = settings.customProviders.find( + (item) => item.id === activeSelectedModel.customProviderId, + ); + if (!provider) { + return undefined; + } + return findProviderModelConfig(provider, activeSelectedModel.model).contextWindow; + }, [settings.customProviders, activeSelectedModel]); + const currentChatProvider = useMemo(() => { + if (!activeSelectedModel) { + return undefined; + } + return settings.customProviders.find( + (item) => item.id === activeSelectedModel.customProviderId, + ); + }, [settings.customProviders, activeSelectedModel]); + const chatRuntimeReasoningOptions = useMemo( + () => + getChatRuntimeReasoningLevelsForProvider({ + providerId: currentChatProvider?.type, + requestFormat: currentChatProvider?.requestFormat, + modelId: activeSelectedModel?.model, + }), + [currentChatProvider?.requestFormat, currentChatProvider?.type, activeSelectedModel?.model], + ); + const chatRuntimeThinkingAlwaysOn = useMemo( + () => + isThinkingAlwaysOnForModel( + currentChatProvider?.type ?? "claude_code", + activeSelectedModel?.model, + ), + [currentChatProvider?.type, activeSelectedModel?.model], + ); + const chatRuntimeControlsForCurrentProvider = useMemo( + () => + normalizeChatRuntimeControlsForProvider(settings.chatRuntimeControls, { + providerId: currentChatProvider?.type, + requestFormat: currentChatProvider?.requestFormat, + modelId: activeSelectedModel?.model, + }), + [ + currentChatProvider?.requestFormat, + currentChatProvider?.type, + settings.chatRuntimeControls, + activeSelectedModel?.model, + ], + ); + const handleChatRuntimeControlsChange = useCallback( + (patch: Partial) => { + setSettings((prev) => ({ + ...prev, + chatRuntimeControls: updateChatRuntimeControlsForProvider(prev.chatRuntimeControls, patch, { + providerId: currentChatProvider?.type, + requestFormat: currentChatProvider?.requestFormat, + modelId: activeSelectedModel?.model, + }), + })); + }, + [ + currentChatProvider?.requestFormat, + currentChatProvider?.type, + setSettings, + activeSelectedModel?.model, + ], + ); + const isAgentDevExecutionMode = isAgentDevMode(settings.system.executionMode); + + const modelOptions = useMemo( + () => buildModelOptions(settings, { floatSelectedFirst: false }), + [settings], + ); + const selectedValue = activeSelectedModel + ? toModelValue(activeSelectedModel.customProviderId, activeSelectedModel.model) + : undefined; + + // 选择器切换:写当前会话的本地 override(下次发消息随 selected_model 落库), + // 并同步更新「新会话默认模型」(最近使用语义,走既有 settings 同步通道)。 + const handleSelectModel = useCallback( + (selection: SelectedModel) => { + const targetConversationId = displayedConversationId.trim(); + if (targetConversationId) { + setConversationModelOverrides((prev) => { + const next = new Map(prev); + next.set(targetConversationId, selection); + return next; + }); + } + setSettings((prev) => setSelectedModel(prev, selection)); + }, + [displayedConversationId, setSettings], + ); + + const resourceWorkdir = + sidebarConversationsById.get(displayedConversationId)?.cwd?.trim() || + conversationWorkdirsRef.current.get(displayedConversationId)?.trim() || + (isAgentMode ? activeWorkspaceProjectPath || settings.system.workdir.trim() : ""); + const workspaceResources = useMemo( + () => resolveWorkspaceResources(settings, resourceWorkdir), + [resourceWorkdir, settings], + ); + const skillsEnabled = workspaceResources.skillsEnabled && isAgentMode; + const selectedSkillNames = useMemo( + () => (skillsEnabled ? workspaceResources.skillNames : []), + [skillsEnabled, workspaceResources.skillNames], + ); + const { availableSkills, skillsRootDir } = useChatSkills({ + skillsEnabled: settings.skills.enabled && isAgentMode, + selectedSkillNames: settings.skills.selected, + setSettings, + }); + const { enabledComposerSkills, codeReviewSkill } = useComposerSkillSelection( + availableSkills, + selectedSkillNames, + skillsEnabled, + ); + + const canShareHistory = Boolean( + api && + settings.remote.enabled && + settings.remote.gatewayUrl.trim() && + settings.remote.token.trim(), + ); + // Sidebar rows, running dots, and project activity all render inside + // from the sidebar store — no app-root memos. + const currentConversationPersistedCwd = + sidebarConversationsById.get(displayedConversationId)?.cwd?.trim() || ""; + const currentConversationRuntimeWorkdir = + conversationWorkdirsRef.current.get(displayedConversationId)?.trim() || ""; + const displayedConversationWorkdir = + currentConversationPersistedCwd || + currentConversationRuntimeWorkdir || + (isAgentMode ? activeWorkspaceProjectPath || settings.system.workdir.trim() : ""); + displayedConversationWorkdirRef.current = displayedConversationWorkdir; + // Switching conversations keeps every conversation's uploads, but a workdir + // change within the same conversation (a draft switching projects) + // invalidates them (staged uploads stay readable, workspace picks do not), + // and a mode flip away from tools invalidates all of them — mirroring the + // GUI-side rule in usePendingUploads. + useEffect(() => { + const executionMode = settings.system.executionMode; + const previous = pendingUploadContextRef.current; + pendingUploadContextRef.current = { + conversationId: displayedConversationId, + workdir: displayedConversationWorkdir, + executionMode, + }; + if (!previous) return; + if (previous.executionMode !== executionMode) { + clearPendingUploads(); + return; + } + if (previous.conversationId !== displayedConversationId) return; + if (previous.workdir === displayedConversationWorkdir) return; + setPendingUploadsForConversation(displayedConversationId, []); + }, [ + clearPendingUploads, + displayedConversationId, + displayedConversationWorkdir, + settings.system.executionMode, + setPendingUploadsForConversation, + ]); + useEffect(() => { + if (!api || !displayedConversationId) { + queuedChatTurnsRef.current = []; + chatQueueConversationIdRef.current = ""; + chatQueueRevisionRef.current = 0; + setQueuedChatTurns([]); + setChatQueueRevision(0); + return; + } + if (chatQueueConversationIdRef.current !== displayedConversationId) { + queuedChatTurnsRef.current = []; + chatQueueConversationIdRef.current = displayedConversationId; + chatQueueRevisionRef.current = 0; + setQueuedChatTurns([]); + setChatQueueRevision(0); + } + let cancelled = false; + void api + .chatQueueGet(displayedConversationId) + .then((response) => { + if (!cancelled) applyChatQueueSnapshot(response.snapshot); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [api, applyChatQueueSnapshot, displayedConversationId]); + const queuedChatTurnsForDisplayedConversation = useMemo( + () => + queuedChatTurns.map((item) => ({ + id: item.id, + previewText: item.previewText, + fileCount: item.fileCount, + })), + [displayedConversationId, queuedChatTurns], + ); + const terminalProjectPath = isAgentMode ? activeWorkspaceProjectPath.trim() : ""; + const terminalProjectPathKey = terminalProjectPath + ? workspaceProjectPathKey(terminalProjectPath) + : ""; + const { + rightDockProjectState, + rightDockFileTreeState, + rightDockFileTreeOpen, + associatedSshHostIds, + handleChatTranscriptWidthChange, + handleRightDockWidthChange, + handleRightDockProjectStateChange, + handleRightDockFileTreeStateChange, + handleSshProjectHostIdsChange, + } = useRightDockSettings({ settings, setSettings, terminalProjectPathKey }); + const rightDockTunnelOpen = isRightDockSingletonTabOpen( + settings.customSettings, + terminalProjectPathKey, + "tunnel", + ); + const rightDockSshTunnelOpen = isRightDockSingletonTabOpen( + settings.customSettings, + terminalProjectPathKey, + "sshTunnel", + ); + const projectToolsDisabledMessage = !settingsSyncReady + ? "Syncing desktop settings..." + : !isAgentMode + ? "Project tools require Agent project mode." + : !terminalProjectPath + ? "Select a project to use project tools." + : undefined; + const terminalDisabledMessage = + projectToolsDisabledMessage ?? + (!settings.remote.enableWebTerminal + ? "Enable WebUI Terminal in desktop Remote settings." + : undefined); + const webTerminalSessionsEnabled = + settings.remote.enableWebTerminal || settings.remote.enableWebSshTerminal; + const { + workspaceEditorMounted, + workspaceEditorOpen, + workspaceEditorCleanupPending, + workspaceEditorOpenRequest, + workspaceEditorCloseRequestId, + workspaceFilePreviewMounted, + workspaceFilePreviewOpen, + workspaceFilePreviewOpenRequest, + workspaceSshTerminalMounted, + workspaceSshTerminalOpen, + workspaceSshTerminalOpenRequest, + terminalSessions, + terminalSessionsLoaded, + setTerminalSessions, + terminalSessionsVersionRef, + terminalStatusSessionIdRef, + projectTerminalSessions, + openWorkspaceEditorFile, + openWorkspaceFilePreview, + handleWorkspaceEditorHide, + handleWorkspaceEditorClosed, + requestWorkspaceFilePreviewClose, + handleWorkspaceFilePreviewClosed, + handleOpenWorkspaceFile, + handleOpenSshTerminal, + handleProjectTerminalSessionsChange, + resetTerminalSessions, + hideWorkspaceSshTerminalOverlay, + } = useProjectToolsRuntime({ + terminalClient, + settingsSyncReady, + isAgentMode, + webTerminalSessionsEnabled, + statusOnline: status?.online, + statusSessionId: status?.session_id, + terminalProjectPath, + terminalProjectPathKey, + rightDockFileTreeOpen, + rightDockSshTunnelOpen, + }); + resetProjectToolsRuntimeRef.current = resetTerminalSessions; + const gitDisabledMessage = !settings.remote.enableWebGit + ? "WebUI Git is disabled in desktop Remote settings." + : undefined; + // Agent offline no longer disables the panel: state still renders (the + // Link badge shows offline) and mutations fail server-side with a clear + // message. + const tunnelEnabled = settingsSyncReady && settings.remote.enableWebTunnels === true; + const tunnelDisabledMessage = !settingsSyncReady + ? translate("chat.runtime.tunnelSettingsSyncing", settings.locale) + : !settings.remote.enableWebTunnels + ? translate("projectTools.tunnelWebDisabled", settings.locale) + : undefined; + const workspaceActivityClient = useMemo( + () => (api ? createGatewayWorkspaceActivityClient(api) : null), + [api], + ); + const { + gitReviewFocusRequest, + handleGitReviewFocusRequestHandled, + handleChangedFileReveal, + changedFilesActions, + } = useChangedFilesActions({ + terminalProjectPathKey, + setRightDockOpen, + setSettings, + onOpenFile: handleOpenWorkspaceFile, + }); + const notifyChatFileLinkError = useCallback( + (message: string) => addNotify("error", message), + [addNotify], + ); + const readChatFileLinkError = useCallback( + (error: unknown) => asErrorMessage(error, "The linked file could not be opened."), + [], + ); + const handleOpenChatFileLink = useChatFileLinkNavigation({ + conversationId: displayedConversationId, + conversationWorkdir: displayedConversationWorkdir, + terminalProjectPathKey, + notifyError: notifyChatFileLinkError, + onRevealInFileTree: handleChangedFileReveal, + openWorkspaceEditorFile, + openWorkspaceFilePreview, + getErrorMessage: readChatFileLinkError, + }); + const { + isSuggestionTyping, + handleRightDockInsertFileMention, + handleRightDockInsertCommitMention, + handleRightDockInsertGitFileMention, + handleInsertCodeMention, + handleEmptyStateSuggestion, + } = useComposerActions(composerRef); + const handleRightDockInsertCodeReviewSkill = useInsertCodeReviewSkill({ + composerRef, + codeReviewSkill, + setSettings, + }); + const handleRightDockClose = useCallback(() => { + setRightDockOpen(false); + }, []); + useEffect(() => { + if (activeView !== "chat") { + return; + } + + const targetConversationId = displayedConversationId.trim(); + if (!targetConversationId) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + const composer = composerRef.current; + if ( + !composer || + (composerDraftOwnerRef.current === targetConversationId && composer.hasContent()) + ) { + return; + } + restoreCachedComposerDraft(targetConversationId); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [activeView, displayedConversationId]); + + const displayedConversationSummary = useMemo(() => { + const displayedId = displayedConversationId.trim(); + if (!displayedId || isLocalDraftConversationId(displayedId)) { + return null; + } + return sidebarConversationsById.get(displayedId) ?? null; + }, [displayedConversationId, sidebarConversationsById]); + const activeProjectBrowserTitle = isAgentMode ? (activeWorkspaceProject?.name.trim() ?? "") : ""; + const displayedConversationTitle = useMemo( + () => + resolveConversationBrowserTitle({ + conversation: displayedConversationSummary, + conversationId: displayedConversationId, + projectName: activeProjectBrowserTitle, + isLocalDraftConversation: isLocalDraftConversationId(displayedConversationId), + newConversationTitle: NEW_CONVERSATION_BROWSER_TITLE, + }), + [activeProjectBrowserTitle, displayedConversationId, displayedConversationSummary], + ); + const browserTitle = useMemo(() => { + if (historyShareToken) { + return SHARED_HISTORY_BROWSER_TITLE; + } + if (!token.trim()) { + return DEFAULT_BROWSER_TITLE; + } + if (activeView === "skills-hub") { + return SKILLS_HUB_BROWSER_TITLE; + } + if (activeView === "mcp-hub") { + return MCP_HUB_BROWSER_TITLE; + } + return displayedConversationTitle || DEFAULT_BROWSER_TITLE; + }, [activeView, displayedConversationTitle, historyShareToken, token]); + const historyDetailLoadingTitle = useMemo(() => { + const selectedId = selectedHistoryId.trim(); + if (!selectedId) { + return ""; + } + const item = sidebarConversationsById.get(selectedId); + return item?.title ?? ""; + }, [selectedHistoryId, sidebarConversationsById]); + const transcriptRows = displayedTranscript.rows; + const taskProgressSnapshot = useMemo( + () => selectLatestTaskProgress(transcriptRows), + [transcriptRows], + ); + // 用量环读数(缺陷 #6):最近 assistant 轮次的真实 usage;压缩后回退检查点估算。 + // 不再以逐帧变化的标量 prop 传入 memo 化的 ChatComposerBar(那会让整枚 composer + // 流式期间每帧重渲染、打字卡顿),而是构造订阅隔离的 source:订阅显示会话的 + // transcript store,getContextUsageTokens 惰性 derive 并按 store revision 缓存, + // revision 未变直接复用——只有环自身经 useSyncExternalStore 重渲染。 + // 本 memo 是 contextUsageTokens 的唯一消费者迁移点:迁移后 GatewayApp 不再持有 + // 标量读数(无 mobile header 等其他消费者)。 + const contextUsageTokensSource = useMemo(() => { + const store = displayedConversationId + ? transcriptStoreRegistry.get(displayedConversationId) + : null; + if (!store) { + return { subscribe: () => () => {}, getContextUsageTokens: () => undefined }; + } + let cache: { revision: number; value: number | undefined } | null = null; + return { + // store 的 subscribe/getSnapshot 是闭包属性,可安全解绑传递。 + subscribe: store.subscribe, + getContextUsageTokens: () => { + const snapshot = store.getSnapshot(); + if (cache && cache.revision === snapshot.revision) { + return cache.value; + } + const value = deriveContextUsageTokens(snapshot.rows); + cache = { revision: snapshot.revision, value }; + return value; + }, + }; + }, [displayedConversationId, transcriptStoreRegistry]); + // 当前会话的待审批工具:遍历渲染中的 transcript,筛出带 __toolApprovalPending 标记 + // 且尚无结果的 tool call(与 ToolCallItem 判定同源)。用于输入框上方的集中审批栏, + // 取代埋在各折叠项里的分散卡片。快照 revision 变化时经 useConversationChat 重渲染, + // 本 memo 随之重算,无需 subscribeToolApprovals。 + const pendingToolApprovals = useMemo(() => { + const out: { toolCallId: string; toolName: string; summary?: string; deadlineAt?: number }[] = + []; + for (const row of transcriptRows) { + if (row.kind !== "assistant") continue; + for (const round of row.rounds) { + for (const block of round.blocks) { + if (block.kind !== "tool") continue; + const { toolCall, toolResult } = block.item; + if (toolResult) continue; + if (!readToolApprovalPending(toolCall.arguments)) continue; + out.push({ + toolCallId: toolCall.id, + toolName: toolCall.name, + summary: readToolApprovalSummary(toolCall.arguments), + deadlineAt: readToolApprovalDeadlineAt(toolCall.arguments) ?? undefined, + }); + } + } + } + return out; + }, [transcriptRows]); + const approvalBar = + pendingToolApprovals.length > 0 ? ( + submitToolApprovalDecision(toolCallId, decision)} + onDecideAll={async (decision) => { + for (const item of pendingToolApprovals) { + await submitToolApprovalDecision(item.toolCallId, decision); + } + }} + /> + ) : null; + const transcriptLiveStartIndex = displayedTranscript.liveStartIndex; + const transcriptFloors = useMemo(() => buildFloorEntries(transcriptRows), [transcriptRows]); + // Row count gates everything visual (empty state, error banner, loading + // screen): entryCount can be non-zero while nothing renders (meta-only + // entries), and hiding an error behind an invisible entry would strand it. + const displayedTranscriptRowCount = transcriptRows.length; + const transcriptHistoryLoading = historyDetailLoading && displayedTranscriptRowCount === 0; + const selectedHistoryHasMore = + selectedHistory?.conversation_id === displayedConversationId && + selectedHistory.has_more === true; + const loadingOlderHistory = fullHistoryLoading && displayedTranscriptRowCount > 0; + const handleLoadEarlierHistory = useCallback(() => { + if (!api || !displayedConversationId) { + return; + } + // Grow the loaded window upward by one page through the id-preserving + // enrich path; no-ops while a run is streaming. + setFullHistoryLoading(true); + void refreshDisplayedConversationHistorySnapshot(displayedConversationId, api, { + extendMessages: HISTORY_DETAIL_LOAD_EARLIER_PAGE_MESSAGES, + }).finally(() => { + setFullHistoryLoading(false); + }); + }, [api, displayedConversationId, refreshDisplayedConversationHistorySnapshot]); + useEffect(() => { + if (typeof document === "undefined") { + return; + } + document.title = browserTitle; + }, [browserTitle]); + const transcriptBusy = displayedConversationBusy; + // Pipeline pending (pre-first-token) shows the preparing status until the + // stream's own tool_status takes over. + const displayedHasPendingCommand = + displayedConversationId !== "" && chatCommandPipeline.hasPending(displayedConversationId); + const transcriptToolStatus = + displayedTranscript.toolStatus ?? + (displayedHasPendingCommand ? CHAT_RUNTIME_PREPARING_STATUS : null); + const transcriptToolStatusIsCompaction = displayedTranscript.toolStatusIsCompaction; + const composerIsSending = transcriptBusy; + const transcriptError = displayedTranscriptRowCount === 0 ? null : chatError; + const composerCompactionBlocked = transcriptToolStatusIsCompaction; + // 手动压缩:受理回包只表示桌面开始处理;按钮保持 pending,直到同一 + // operationId 的 manual_compaction_result 终态到达。探针拒绝(低于阈值/无内容/忙) + // 时桌面同步回 accepted:false + message,走 !accepted 分支即时清 pending。 + const handleManualCompact = useCallback(async () => { + const conversationIdValue = getDisplayedConversationId(); + // 缺陷 #3:只在“同会话”已有 pending 时拒绝(静默 return 即可,UI 已 blocked); + // 其他会话的 pending 不再屏蔽本会话的压缩请求。 + if (!api || !conversationIdValue || manualCompactPendingRef.current.has(conversationIdValue)) { + return; + } + const operationId = createUuid(); + const pendingRequest: ManualCompactPendingRequest = { + conversationId: conversationIdValue, + operationId, + workdir: displayedConversationWorkdir, + startedAt: Date.now(), + }; + setManualCompactPendingRequest(pendingRequest); + try { + const response = await api.chatQueueCompactNow(conversationIdValue, operationId); + if ( + !response.accepted && + clearManualCompactPendingRequest(conversationIdValue, operationId) && + isDisplayedConversation(conversationIdValue) + ) { + setChatError(response.message || translate("chat.manualCompactRejected", settings.locale)); + } + } catch (error) { + if ( + clearManualCompactPendingRequest(conversationIdValue, operationId) && + isDisplayedConversation(conversationIdValue) + ) { + setChatError( + asErrorMessage(error, translate("chat.manualCompactRejected", settings.locale)), + ); + } + } + }, [ + api, + clearManualCompactPendingRequest, + displayedConversationWorkdir, + setManualCompactPendingRequest, + settings.locale, + ]); + const chatProtocolIncompatible = isChatRuntimeProtocolIncompatible(status); + const chatProtocolIncompatibleMessage = chatProtocolIncompatible + ? translate("chat.runtime.protocolIncompatible", settings.locale) + : null; + const sidebarSectionsDisabled = shouldDisableGatewaySidebarSections({ + connectionLost: gatewayConnectionLost, + agentStatusFresh: sidebarAgentStatusFresh, + agentOnline: status?.online, + }); + const composerInputDisabled = + !status?.online || + chatProtocolIncompatible || + historyDetailLoading || + composerCompactionBlocked; + const composerPlaceholder = chatProtocolIncompatible + ? translate("chat.runtime.protocolIncompatiblePlaceholder", settings.locale) + : composerCompactionBlocked + ? translate("chat.compactingContextWait", settings.locale) + : historyDetailLoading + ? "正在加载会话历史,请稍候..." + : enabledComposerSkills.length > 0 + ? translate("chat.inputHintWithSkills", settings.locale) + : translate("chat.inputHint", settings.locale); + const canDropUpload = + status?.online === true && + isAgentMode && + Boolean(displayedConversationWorkdir.trim()) && + !isUploadingFiles && + !composerInputDisabled; + const fileDropTitle = canDropUpload + ? translate("chat.upload.dropReady", settings.locale) + : status?.online !== true + ? translate("chat.upload.dropBusy", settings.locale) + : !isAgentMode + ? translate("chat.upload.onlyInTools", settings.locale) + : !displayedConversationWorkdir.trim() + ? translate("chat.upload.requireWorkdir", settings.locale) + : translate("chat.upload.dropBusy", settings.locale); + const fileDropDescription = canDropUpload + ? translate("chat.upload.dropHint", settings.locale) + : translate("chat.upload.dropDisabledHint", settings.locale); + const fileDropLimitHint = formatTranslation(translate("chat.upload.dropLimit", settings.locale), { + max: MAX_UPLOAD_FILES, + }); + + const handleFileDragOver = useCallback( + (event: DragEvent) => { + handlePendingFileDragOver(event, canDropUpload); + }, + [canDropUpload, handlePendingFileDragOver], + ); + + const handleFileDrop = useCallback( + (event: DragEvent) => { + handlePendingFileDrop(event, { + canDropUpload, + disabledMessage: fileDropTitle, + }); + }, + [canDropUpload, fileDropTitle, handlePendingFileDrop], + ); + + useEffect(() => { + const nextDisplayedConversationId = displayedConversationId.trim(); + const previousDisplayedConversationId = previousDisplayedConversationIdRef.current.trim(); + previousDisplayedConversationIdRef.current = nextDisplayedConversationId; + if ( + !nextDisplayedConversationId || + !previousDisplayedConversationId || + previousDisplayedConversationId === nextDisplayedConversationId + ) { + return; + } + // Switching away folds the settled turns so revisits start clean. + transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTurns(); + pendingDisplayedConversationAutoBottomRef.current = nextDisplayedConversationId; + }, [displayedConversationId, transcriptStoreRegistry]); + + useLayoutEffect(() => { + const targetConversationId = pendingDisplayedConversationAutoBottomRef.current?.trim() ?? ""; + if ( + !targetConversationId || + historyDetailLoading || + displayedConversationId.trim() !== targetConversationId || + displayedTranscriptRowCount === 0 + ) { + return; + } + + transcriptFollow.stickToBottom(); + pendingDisplayedConversationAutoBottomRef.current = null; + }, [ + displayedConversationId, + displayedTranscriptRowCount, + historyDetailLoading, + transcriptFollow, + ]); + + if (historyShareToken) { + return ( + + + + ); + } + + if (!token) { + return ( + { + setLoginGatewayUrl(nextGatewayUrl); + if (authError) { + setAuthError(null); + } + }} + onTokenChange={(nextToken) => { + setLoginToken(nextToken); + if (authError) { + setAuthError(null); + } + }} + onSubmit={handleLoginSubmit} + /> + ); + } + + if (!api) { + return null; + } + + if (!settingsSyncReady) { + return ( + +
+
+
+
+ +
+
+
+
+ ); + } + + return ( + + +
+ { + const files = Array.from(event.currentTarget.files ?? []); + void handleImportReadableFiles(files); + event.currentTarget.value = ""; + }} + /> + +
+ setSidebarOpen(false)} + onOpenSettings={() => openSettings()} + onOpenSkillsHub={handleSidebarOpenSkillsHub} + onOpenMcpHub={handleSidebarOpenMcpHub} + /> + + {shareConversation ? ( + + ) : null} + + {sharedManagerOpen ? ( + setSharedManagerOpen(false)} + /> + ) : null} + + {projectPickerOpen ? ( + setProjectPickerOpen(false)} + onSelect={handleWorkdirPickerSelect} + /> + ) : null} + + {workspaceCreateModalOpen ? ( + setWorkspaceCreateModalOpen(false)} + /> + ) : null} + + + {confirmDialog} + +
+
+ setSidebarOpen(true)} + initialSkills={availableSkills} + initialSkillsRootDir={skillsRootDir} + className="contents" + chat={{ + containerProps: { + className: "gateway-chat-frame zone-font-scale", + style: { + "--zone-font-scale": settings.customSettings.fontScale.chat, + } as CSSProperties, + onDragEnter: handleFileDragEnter, + onDragOver: handleFileDragOver, + onDragLeave: handleFileDragLeave, + onDrop: handleFileDrop, + }, + onSelectExecutionMode: (mode) => + setSettings((prev) => updateExecutionModeFromChatSelection(prev, mode)), + hasModels: modelOptions.length > 0, + currentModelLabel, + modelOptions, + selectedValue, + sidebarOpen, + onSelectModel: handleSelectModel, + onOpenSettings: openSettings, + onToggleTheme: () => + setSettings((prev) => ({ + ...prev, + theme: getNextTheme(prev.theme), + })), + onOpenSidebar: () => setSidebarOpen(true), + trailingActions: ( + <> + setRightDockOpen((open) => !open)} + /> + + } + onLogout={handleLogout} + /> + + ), + headerOverlay: ( + // Zero-height anchor: NotifyToast positions itself below + // the header's bottom edge, mirroring the GUI placement. +
+ +
+ ), + content: ( + <> + {statusError ? ( +
{statusError}
+ ) : null} + {chatProtocolIncompatibleMessage && !statusError ? ( +
+ {chatProtocolIncompatibleMessage} +
+ ) : null} + {settingsSyncError ? ( +
{settingsSyncError}
+ ) : null} + {chatError && displayedTranscriptRowCount === 0 ? ( +
{chatError}
+ ) : null} + +
+
+ + + 0} + onOpenSettings={openSettings} + hasMoreHistory={selectedHistoryHasMore} + isLoadingMoreHistory={loadingOlderHistory} + onLoadEarlierHistory={ + selectedHistoryHasMore ? handleLoadEarlierHistory : undefined + } + showUsage={isAgentDevExecutionMode} + usageContextWindow={currentModelContextWindow} + workspaceRoot={displayedConversationWorkdir} + onOpenFileLink={handleOpenChatFileLink} + gitClient={gitClient} + onLoadUploadedImagePreview={handleLoadUploadedImagePreview} + onResendFromEdit={handleResendFromEdit} + onBranchConversation={handleBranchConversation} + branchPendingMessageId={branchPendingMessageId} + onSuggestionSelect={handleEmptyStateSuggestion} + suggestionsDisabled={isSuggestionTyping} + /> + + + + {displayedTranscriptRowCount > 0 && !conversationOpenState.showOverlay ? ( + + ) : null} + {conversationOpenState.showOverlay ? ( + + ) : null} +
+ {!transcriptFollowing ? ( + + ) : null} + { + if ( + submitInFlightRef.current || + isUploadingFiles || + isImportingPastedTextRef.current || + composerInputDisabled + ) { + return; + } + if (queuedChatEditSessionRef.current) { + submitInFlightRef.current = true; + void (async () => { + try { + await commitQueuedChatEdit(); + } finally { + submitInFlightRef.current = false; + } + })(); + return; + } + if ( + displayedConversationBusyRef.current || + queuedChatTurnsForDisplayedConversation.length > 0 + ) { + submitInFlightRef.current = true; + void (async () => { + try { + await submitCurrentComposerToGuiQueue("append"); + } finally { + submitInFlightRef.current = false; + } + })(); + return; + } + submitInFlightRef.current = true; + void (async () => { + try { + const draft = composerRef.current?.getDraft() ?? null; + // Capture the send target before the paste import + // awaits: switching conversations mid-import must + // not reroute the message or clear the composer of + // the newly displayed conversation. + const sendConversationId = getDisplayedConversationId(); + let text: string; + let files: PendingUploadedFile[]; + try { + const materialized = draft + ? await materializeComposerDraftForSend( + draft, + pendingUploadedFiles, + displayedConversationWorkdir, + ) + : { text: "", uploadedFiles: pendingUploadedFiles }; + text = materialized.text; + files = materialized.uploadedFiles; + } catch (error) { + addNotify("error", asErrorMessage(error, "大段粘贴内容导入失败")); + return; + } + + if (!text && files.length === 0) { + return; + } + if (getDisplayedConversationId() === sendConversationId) { + composerRef.current?.clear(); + } + setPendingUploadsForConversation(sendConversationId, []); + void sendChat(text, { + conversationId: sendConversationId, + uploadedFiles: files, + runtimeControls: chatRuntimeControlsForCurrentProvider, + }).catch(() => { + updatePendingUploadsForConversation( + sendConversationId, + (current) => mergePendingUploadedFiles(current, files), + ); + }); + } finally { + submitInFlightRef.current = false; + } + })(); + }} + onStop={() => { + const nextQueuedTurn = queuedChatTurnsForDisplayedConversation[0]; + if (nextQueuedTurn) { + // Keep WebUI's stop button aligned with the desktop + // composer: stop the active run, then drain the queue. + runQueuedTurnNow(nextQueuedTurn.id); + return; + } + void cancelChat(displayedConversationId); + }} + onPrepareChatRuntime={() => { + if (!api || historyShareToken) { + return; + } + void prepareChatRuntime( + "composer-focus", + api, + CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, + ).catch(() => undefined); + }} + onComposerBusyChange={handleComposerBusyChange} + onChatRuntimeControlsChange={handleChatRuntimeControlsChange} + onPickReadableFiles={() => fileInputRef.current?.click()} + onPasteFiles={handleImportReadableFiles} + onLoadUploadedImagePreview={handleLoadUploadedImagePreview} + loadHistoryPrompts={loadComposerHistoryPrompts} + pendingUploadedFiles={pendingUploadedFiles} + onRemovePendingUpload={(relativePath) => { + updatePendingUploadsForConversation( + getDisplayedConversationId(), + (current) => + current.filter((file) => file.relativePath !== relativePath), + ); + }} + queuedTurns={queuedChatTurnsForDisplayedConversation} + onRunQueuedTurnNow={runQueuedTurnNow} + onMoveQueuedTurnUp={moveQueuedTurnUp} + onEditQueuedTurn={editQueuedTurn} + onRemoveQueuedTurn={removeQueuedTurn} + taskProgressBar={ + + } + approvalBar={approvalBar} + /> + {isFileDropActive ? ( + + ) : null} +
+ + ), + }} + workspaceOverlays={ + + } + /> +
+
+ + {terminalClient ? ( + + ) : null} + + {resourceSettingsProject ? ( + setResourceSettingsProject(null)} + onSave={(draft) => { + setSettings((prev) => + updateWorkspaceResourceSettings(prev, resourceSettingsProject.path, draft), + ); + setResourceSettingsProject(null); + }} + /> + ) : null} + + {settingsOpen ? ( +
+ { + if (!api) return; + await api.listAgents(); + handleActiveAgentChange(api.getActiveAgent()); + }} + /> +
+ ) : null} +
+
+
+ ); +} diff --git a/crates/agent-mobile/src/app/HistorySwitchLoadingOverlay.tsx b/crates/agent-mobile/src/app/HistorySwitchLoadingOverlay.tsx new file mode 100644 index 000000000..49b7fc3f0 --- /dev/null +++ b/crates/agent-mobile/src/app/HistorySwitchLoadingOverlay.tsx @@ -0,0 +1,20 @@ +import { Loader2 } from "@liveagent/ui/components/IconSet"; +import type { AppSettings } from "@/lib/settings"; + +export function HistorySwitchLoadingOverlay(props: { locale: AppSettings["locale"] }) { + const label = props.locale === "en-US" ? "Loading conversation..." : "正在加载对话..."; + + return ( +
+
+ + {label} +
+
+ ); +} diff --git a/crates/agent-mobile/src/app/UserMenu.tsx b/crates/agent-mobile/src/app/UserMenu.tsx new file mode 100644 index 000000000..a2549b036 --- /dev/null +++ b/crates/agent-mobile/src/app/UserMenu.tsx @@ -0,0 +1,85 @@ +import { ChevronDown, LogOut, User } from "@liveagent/ui/components/IconSet"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@liveagent/ui/components/ui/dropdown-menu"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import type { ReactNode } from "react"; + +type UserMenuProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + userMenuLabel: string; + userAvatarLabel: string; + agentStatus: "online" | "offline" | "unknown"; + agentSelector?: ReactNode; + onLogout: () => void; +}; + +export function UserMenu(props: UserMenuProps) { + const { + open, + onOpenChange, + userMenuLabel, + userAvatarLabel, + agentStatus, + agentSelector, + onLogout, + } = props; + const { t } = useLocale(); + const statusLabel = + agentStatus === "online" + ? t("settings.devicesOnlineStatus") + : agentStatus === "offline" + ? t("settings.devicesOfflineStatus") + : t("settings.devicesUnknownStatus"); + const statusDotClass = + agentStatus === "online" + ? "bg-emerald-500" + : agentStatus === "offline" + ? "bg-rose-500" + : "bg-muted-foreground/50"; + + return ( + + + } + > + + {userAvatarLabel || } + + {statusLabel} + + + + + + {agentSelector} + + + + 退出登录 + + + + ); +} diff --git a/crates/agent-mobile/src/app/chatDraft.ts b/crates/agent-mobile/src/app/chatDraft.ts new file mode 100644 index 000000000..9d54ff1ce --- /dev/null +++ b/crates/agent-mobile/src/app/chatDraft.ts @@ -0,0 +1,37 @@ +import type { MentionComposerLargePaste } from "@liveagent/ui/components/chat/MentionComposer"; +import { + buildPastedTextFileName, + validateImportedPastedTextFiles, +} from "@liveagent/ui/lib/chat/composerDraft"; +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { importReadableFiles } from "@/lib/uploadReadableFiles"; + +export { buildTextFromComposerDraft } from "@liveagent/ui/lib/chat/composerDraft"; + +export async function importPastedTextsAsFiles(params: { + token: string; + agentId: string; + workdir: string; + pastes: MentionComposerLargePaste[]; +}) { + const { token, agentId, workdir, pastes } = params; + const normalizedWorkdir = workdir.trim(); + if (!normalizedWorkdir) { + throw new Error("项目目录未选择,无法发送大段粘贴内容。"); + } + if (pastes.length === 0) { + return { + files: [], + fileByPasteId: new Map(), + }; + } + + const textFiles = pastes.map( + (paste, index) => + new File([paste.text], buildPastedTextFileName(paste, index), { + type: "text/plain", + }), + ); + const response = await importReadableFiles(token, agentId, normalizedWorkdir, textFiles); + return validateImportedPastedTextFiles(pastes, response.files, response.skipped); +} diff --git a/crates/agent-mobile/src/app/chatEventUtils.ts b/crates/agent-mobile/src/app/chatEventUtils.ts new file mode 100644 index 000000000..497d0a97e --- /dev/null +++ b/crates/agent-mobile/src/app/chatEventUtils.ts @@ -0,0 +1,107 @@ +import { asRecord, errorMessageWithFallback } from "@liveagent/ui/lib/shared/value"; +import type { ChatEvent, GatewaySelectedModel } from "@/lib/gatewayTypes"; +import { + type AppSettings, + normalizeSelectedModelForProviders, + parseSelectedModelJson, + type SelectedModel, +} from "@/lib/settings"; +import type { ModelProviderSource, TunnelManagerToolChange } from "./types"; + +export { errorMessageWithFallback as asErrorMessage }; + +export function isAbortError(error: unknown) { + if ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ) { + return true; + } + const message = error instanceof Error ? error.message : String(error ?? ""); + const normalized = message.trim().toLowerCase(); + return ( + normalized.includes("cancelled") || + normalized.includes("canceled") || + normalized.includes("已取消") || + normalized.includes("abort") || + normalized.includes("aborted") + ); +} + +export function readChatEventTitle(event: ChatEvent): string { + if ("title" in event && typeof event.title === "string") { + return event.title.trim(); + } + return ""; +} + +export function isChatEventTitleFinal(event: ChatEvent) { + return event.type === "done" || ("titleFinal" in event && event.titleFinal === true); +} + +export function readTunnelManagerToolChange(event: ChatEvent): TunnelManagerToolChange | null { + if (event.type !== "tool_result" || event.isError === true) { + return null; + } + const details = asRecord(event.details); + if (details.kind !== "tunnel_manager") { + return null; + } + const action = typeof details.action === "string" ? details.action.trim() : ""; + if (action !== "create" && action !== "close") { + return null; + } + const tunnel = asRecord(details.tunnel); + const projectPathKey = + (typeof tunnel.projectPathKey === "string" ? tunnel.projectPathKey.trim() : "") || + (typeof tunnel.project_path_key === "string" ? tunnel.project_path_key.trim() : "") || + event.workdir?.trim() || + ""; + return { action, projectPathKey }; +} + +// 会话生效模型的唯一派生点:本地未持久化的切换(override)> +// history-sync 带回的会话持久化选择 > 全局默认(新会话语义)。 +// 前两级都按当前 providers 校验,失效则逐级回退。 +export function resolveActiveModelSelection(params: { + settings: AppSettings; + override?: SelectedModel; + persistedSelectedModelJson?: string; +}): SelectedModel | undefined { + const { settings, override, persistedSelectedModelJson } = params; + return ( + normalizeSelectedModelForProviders(override, settings.customProviders) ?? + normalizeSelectedModelForProviders( + parseSelectedModelJson(persistedSelectedModelJson), + settings.customProviders, + ) ?? + settings.selectedModel + ); +} + +export function buildGatewaySelectedModel( + selectedModel: SelectedModel | undefined, + providers: ModelProviderSource[], +): GatewaySelectedModel | undefined { + if (!selectedModel) { + return undefined; + } + + const provider = providers.find((item) => item.id === selectedModel.customProviderId); + if (!provider) { + return undefined; + } + + return { + customProviderId: provider.id, + model: selectedModel.model, + providerType: provider.type, + }; +} + +export function buildGatewaySystemSettings(settings: AppSettings, workdirOverride?: string) { + return { + executionMode: settings.system.executionMode, + workdir: workdirOverride ?? settings.system.workdir.trim(), + }; +} diff --git a/crates/agent-mobile/src/app/constants.ts b/crates/agent-mobile/src/app/constants.ts new file mode 100644 index 000000000..3d4dc898f --- /dev/null +++ b/crates/agent-mobile/src/app/constants.ts @@ -0,0 +1,22 @@ +export const MAX_UPLOAD_FILES = 9; + +export const PROTECTED_DRAFT_CONVERSATION = "__protected_draft__"; +export const HISTORY_LIST_PAGE_SIZE = 80; +export const HISTORY_DETAIL_INITIAL_MAX_MESSAGES = 360; +// One "load earlier history" click grows the loaded window by this many +// persisted messages (same size as the initial tail window). +export const HISTORY_DETAIL_LOAD_EARLIER_PAGE_MESSAGES = 360; +export const PROJECT_HISTORY_DELETE_PAGE_SIZE = 200; +export const SHARED_HISTORY_LIST_PAGE_SIZE = 200; +export const CHAT_RUNTIME_PREPARE_TIMEOUT_MS = 2_500; +export const CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS = 1_500; +export const CHAT_RUNTIME_KEEP_WARM_INTERVAL_MS = 10_000; +export const CHAT_RUNTIME_PREPARING_STATUS = "Preparing request..."; + +export const DEFAULT_BROWSER_TITLE = "LiveAgent Gateway"; +export const NEW_CONVERSATION_BROWSER_TITLE = "LiveAgent"; +export const SHARED_HISTORY_BROWSER_TITLE = "分享会话"; +export const SKILLS_HUB_BROWSER_TITLE = "Skills Hub"; +export const MCP_HUB_BROWSER_TITLE = "MCP Hub"; + +export const MOBILE_SIDEBAR_MEDIA_QUERY = "(max-width: 820px)"; diff --git a/crates/agent-mobile/src/app/domUtils.ts b/crates/agent-mobile/src/app/domUtils.ts new file mode 100644 index 000000000..7c7f32056 --- /dev/null +++ b/crates/agent-mobile/src/app/domUtils.ts @@ -0,0 +1,5 @@ +import type { DragEvent } from "react"; + +export function dragEventHasFiles(event: DragEvent) { + return Array.from(event.dataTransfer.types).includes("Files"); +} diff --git a/crates/agent-mobile/src/app/historyUtils.ts b/crates/agent-mobile/src/app/historyUtils.ts new file mode 100644 index 000000000..17618d475 --- /dev/null +++ b/crates/agent-mobile/src/app/historyUtils.ts @@ -0,0 +1,90 @@ +import { buildGatewaySettingsSyncPayload } from "@liveagent/ui/lib/settings/sync"; +import { formatConversationTitle } from "@/lib/chatUi"; +import type { ConversationSummary } from "@/lib/gatewayTypes"; +import { type AppSettings, resolveWorkspaceProjects } from "@/lib/settings"; + +function isLocalDraftConversationId(id: string) { + return id.trim().startsWith("__local_draft__:"); +} + +import { getDefaultWorkspaceProjectPath } from "@liveagent/ui/lib/workspaceProjects"; + +import { MOBILE_SIDEBAR_MEDIA_QUERY } from "./constants"; + +export function formatTranslation(template: string, values: Record) { + return Object.entries(values).reduce( + (text, [key, value]) => text.replaceAll(`{${key}}`, String(value)), + template, + ); +} + +export function hasSettingsSyncChanged(prev: AppSettings, next: AppSettings) { + return ( + JSON.stringify(buildGatewaySettingsSyncPayload(prev)) !== + JSON.stringify(buildGatewaySettingsSyncPayload(next)) + ); +} + +export function resolveAppWorkspaceProjects(settings: AppSettings): AppSettings { + return { + ...settings, + system: resolveWorkspaceProjects( + settings.system, + getDefaultWorkspaceProjectPath(settings.system), + ), + }; +} + +export function resolveConversationTitle( + summary: ConversationSummary | null, + fallbackConversationId: string, +) { + return formatConversationTitle(summary, fallbackConversationId); +} + +export function hasLocalDraftConversation(params: { + conversationId: string; + selectedHistoryId: string; + requestedConversationId?: string; + chatMessageCount: number; + pendingUploadCount: number; + draftPinned: boolean; +}) { + const { + conversationId, + selectedHistoryId, + requestedConversationId = "", + chatMessageCount, + pendingUploadCount, + draftPinned, + } = params; + + const isDraftConversation = conversationId === "" || isLocalDraftConversationId(conversationId); + const isDraftSelected = selectedHistoryId === "" || selectedHistoryId === conversationId; + + return ( + isDraftConversation && + isDraftSelected && + requestedConversationId === "" && + (draftPinned || chatMessageCount > 0 || pendingUploadCount > 0) + ); +} + +export function resolveVisibleConversationId(selectedHistoryId: string, conversationId: string) { + const selectedId = selectedHistoryId.trim(); + if (selectedId) { + return selectedId; + } + return conversationId.trim(); +} + +export function isMobileSidebarLayout() { + if (typeof window === "undefined") { + return false; + } + return window.matchMedia(MOBILE_SIDEBAR_MEDIA_QUERY).matches; +} + +export function shouldOpenSidebarByDefault() { + return !isMobileSidebarLayout(); +} diff --git a/crates/agent-mobile/src/app/hooks/useGatewayClients.ts b/crates/agent-mobile/src/app/hooks/useGatewayClients.ts new file mode 100644 index 000000000..8f21de7e1 --- /dev/null +++ b/crates/agent-mobile/src/app/hooks/useGatewayClients.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react"; + +import { getGatewayWebSocketClient } from "@/lib/gatewaySocket"; +import { createGatewayGitClient } from "@/lib/git/gatewayGitClient"; +import { createGatewaySftpClient } from "@/lib/sftp/gatewaySftpClient"; +import { createGatewayTerminalClient } from "@/lib/terminal/gatewayTerminalClient"; + +export function useGatewayClients(token: string) { + const api = useMemo(() => (token ? getGatewayWebSocketClient(token) : null), [token]); + const terminalClient = useMemo(() => (api ? createGatewayTerminalClient(api) : null), [api]); + const sftpClient = useMemo(() => (api ? createGatewaySftpClient(api) : null), [api]); + const gitClient = useMemo(() => (api ? createGatewayGitClient(api) : null), [api]); + + return { + api, + terminalClient, + sftpClient, + gitClient, + }; +} diff --git a/crates/agent-mobile/src/app/hooks/useGatewaySession.ts b/crates/agent-mobile/src/app/hooks/useGatewaySession.ts new file mode 100644 index 000000000..1624a2f70 --- /dev/null +++ b/crates/agent-mobile/src/app/hooks/useGatewaySession.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { normalizeGatewayAccessToken, verifyGatewayAccessToken } from "@/lib/gatewayAuth"; +import { clearGatewayOrigin, setGatewayOrigin } from "@/lib/gatewayOrigin"; +import { resetGatewayWebSocketClient } from "@/lib/gatewaySocket"; +import { clearToken, loadGatewayUrl, loadToken, saveToken } from "@/lib/storage"; + +import { asErrorMessage } from "../chatEventUtils"; + +export function useGatewaySession(historyShareToken: string | null) { + const initialStoredTokenRef = useRef(historyShareToken ? "" : loadToken()); + const initialStoredGatewayUrlRef = useRef(historyShareToken ? "" : loadGatewayUrl()); + const [token, setToken] = useState(""); + const [loginToken, setLoginToken] = useState(initialStoredTokenRef.current); + const [gatewayUrl, setGatewayUrl] = useState(""); + const [loginGatewayUrl, setLoginGatewayUrl] = useState(initialStoredGatewayUrlRef.current); + const [authSubmitting, setAuthSubmitting] = useState( + () => + normalizeGatewayAccessToken(initialStoredTokenRef.current) !== "" && + initialStoredGatewayUrlRef.current.trim() !== "", + ); + const [authError, setAuthError] = useState(null); + + useEffect(() => { + const storedToken = normalizeGatewayAccessToken(initialStoredTokenRef.current); + const storedGatewayUrl = initialStoredGatewayUrlRef.current.trim(); + // A token from before the Gateway URL field existed (or one cleared + // independently) has nothing to verify against; fall back to the login + // form instead of guessing an origin. + if (!storedToken || !storedGatewayUrl) { + return; + } + + let cancelled = false; + setAuthError(null); + resetGatewayWebSocketClient(); + + void (async () => { + try { + const normalizedUrl = setGatewayOrigin(storedGatewayUrl); + const verifiedToken = await verifyGatewayAccessToken(storedToken); + if (cancelled) { + return; + } + initialStoredTokenRef.current = verifiedToken; + saveToken(verifiedToken); + setLoginToken(verifiedToken); + setToken(verifiedToken); + setLoginGatewayUrl(normalizedUrl); + setGatewayUrl(normalizedUrl); + } catch (error) { + if (cancelled) { + return; + } + initialStoredTokenRef.current = ""; + clearToken(); + clearGatewayOrigin(); + resetGatewayWebSocketClient(); + setToken(""); + setGatewayUrl(""); + setAuthError(asErrorMessage(error, "Access Token 验证失败。")); + setLoginToken(storedToken); + } finally { + if (!cancelled) { + setAuthSubmitting(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + const login = useCallback(async () => { + const draftToken = loginToken; + const draftGatewayUrl = loginGatewayUrl; + const normalizedToken = normalizeGatewayAccessToken(draftToken); + if (!normalizedToken) { + setAuthError("请输入 Access Token。"); + return; + } + if (!draftGatewayUrl.trim()) { + setAuthError("请输入 Gateway 地址。"); + return; + } + + setAuthSubmitting(true); + setAuthError(null); + resetGatewayWebSocketClient(); + + try { + const normalizedUrl = setGatewayOrigin(draftGatewayUrl); + const verifiedToken = await verifyGatewayAccessToken(draftToken); + initialStoredTokenRef.current = verifiedToken; + saveToken(verifiedToken); + setLoginToken(verifiedToken); + setToken(verifiedToken); + setLoginGatewayUrl(normalizedUrl); + setGatewayUrl(normalizedUrl); + } catch (error) { + initialStoredTokenRef.current = ""; + clearToken(); + clearGatewayOrigin(); + resetGatewayWebSocketClient(); + setToken(""); + setGatewayUrl(""); + setAuthError(asErrorMessage(error, "Access Token 验证失败。")); + } finally { + setAuthSubmitting(false); + } + }, [loginToken, loginGatewayUrl]); + + const clearSession = useCallback(() => { + clearToken(); + clearGatewayOrigin(); + resetGatewayWebSocketClient(); + initialStoredTokenRef.current = ""; + setAuthSubmitting(false); + setAuthError(null); + setLoginToken(""); + setToken(""); + setLoginGatewayUrl(""); + setGatewayUrl(""); + }, []); + + return { + token, + loginToken, + gatewayUrl, + loginGatewayUrl, + authSubmitting, + authError, + setToken, + setLoginToken, + setLoginGatewayUrl, + setAuthError, + login, + clearSession, + }; +} diff --git a/crates/agent-mobile/src/app/hooks/useGatewaySettingsSync.ts b/crates/agent-mobile/src/app/hooks/useGatewaySettingsSync.ts new file mode 100644 index 000000000..5508455ef --- /dev/null +++ b/crates/agent-mobile/src/app/hooks/useGatewaySettingsSync.ts @@ -0,0 +1,242 @@ +import { + type CronSnapshot, + feedCronSnapshot, + feedHooksSnapshot, + type HooksSnapshot, + initAutomation, +} from "@liveagent/ui/lib/automation/index"; +import { setPreferredMonacoNlsLocale } from "@liveagent/ui/lib/monacoNls"; +import { + applyGatewaySettingsSyncPayload, + buildGatewaySettingsSyncUpdatePayload, + type GatewaySettingsSyncPayload, + redactSettingsForWebStorage, +} from "@liveagent/ui/lib/settings/sync"; +import { applyFontFamilies } from "@liveagent/ui/lib/shared/fontFamily"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getGatewayOrigin } from "@/lib/gatewayOrigin"; +import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; +import { + type AppSettings, + normalizeSettings, + resolveEffectiveTheme, + subscribeToSystemThemePreference, +} from "@/lib/settings"; +import { loadToken } from "@/lib/storage"; +import { loadWebSettings, persistWebSettings, type WebSettingsSaveState } from "@/lib/webSettings"; + +import { asErrorMessage } from "../chatEventUtils"; +import { hasSettingsSyncChanged, resolveAppWorkspaceProjects } from "../historyUtils"; + +export function useGatewaySettingsSync(params: { + token: string; + api: GatewayWebSocketClientLike | null; + activeAgentId: string; +}) { + const { token, api, activeAgentId } = params; + const [settings, setSettingsState] = useState(() => loadWebSettings(loadToken())); + const [settingsSyncReady, setSettingsSyncReady] = useState(() => token.trim() === ""); + const [settingsSyncError, setSettingsSyncError] = useState(null); + const [settingsSaveState, setSettingsSaveState] = useState({ + status: "saved", + }); + const settingsSaveSequenceRef = useRef(0); + const settingsSaveChainRef = useRef>(Promise.resolve()); + // Mirrors `settings` so setSettings/applyGatewaySettings can read the latest value + // synchronously without passing a (side-effecting) function into setSettingsState — + // React 18 StrictMode double-invokes functional state updaters in development, + // which would otherwise run those side effects (and any non-idempotent work like + // crypto.randomUUID() inside caller updaters) twice per call. + const settingsRef = useRef(settings); + settingsRef.current = settings; + const [systemThemeVersion, setSystemThemeVersion] = useState(0); + + // Monaco reads NLS globals while the lazy editor module imports monaco-editor. + setPreferredMonacoNlsLocale(settings.locale); + + useEffect(() => { + if (settings.theme !== "system") return; + return subscribeToSystemThemePreference(() => { + setSystemThemeVersion((version) => version + 1); + }); + }, [settings.theme]); + + useEffect(() => { + const root = document.documentElement; + root.classList.toggle("dark", resolveEffectiveTheme(settings.theme) === "dark"); + }, [settings.theme, systemThemeVersion]); + + useEffect(() => { + applyFontFamilies({ + interfaceFontFamily: settings.customSettings.interfaceFontFamily, + chatFontFamily: settings.customSettings.chatFontFamily, + codeFontFamily: settings.customSettings.codeFontFamily, + }); + }, [ + settings.customSettings.interfaceFontFamily, + settings.customSettings.chatFontFamily, + settings.customSettings.codeFontFamily, + ]); + + useEffect(() => { + setSettingsState((prev) => + resolveAppWorkspaceProjects( + normalizeSettings({ + ...prev, + remote: { + ...prev.remote, + gatewayUrl: getGatewayOrigin(), + token: token.trim(), + enabled: token.trim() !== "" || prev.remote.enabled, + }, + }), + ), + ); + }, [token]); + + const queueSettingsSave = useCallback( + (prev: AppSettings, next: AppSettings, fallback: string, syncGateway: boolean) => { + const saveSequence = ++settingsSaveSequenceRef.current; + setSettingsSaveState({ status: "saving" }); + const redactedNext = redactSettingsForWebStorage(next); + const gatewayUpdate = + syncGateway && api + ? buildGatewaySettingsSyncUpdatePayload(prev, next, { + includeProviderApiKeyUpdates: true, + }) + : null; + + settingsSaveChainRef.current = settingsSaveChainRef.current + .catch(() => undefined) + .then(() => { + persistWebSettings(redactedNext); + }) + .then(async () => { + if (gatewayUpdate && Object.keys(gatewayUpdate).length > 0) { + await api?.updateSettings(gatewayUpdate); + } + }) + .then(() => { + if (settingsSaveSequenceRef.current === saveSequence) { + setSettingsSaveState({ status: "saved" }); + } + }) + .catch((error) => { + if (syncGateway && api) { + void api + .getSettings() + .then((payload) => { + const current = settingsRef.current; + const refreshed = redactSettingsForWebStorage( + resolveAppWorkspaceProjects(applyGatewaySettingsSyncPayload(current, payload)), + ); + settingsRef.current = refreshed; + persistWebSettings(refreshed); + setSettingsState(refreshed); + }) + .catch(() => undefined); + } + if (settingsSaveSequenceRef.current === saveSequence) { + setSettingsSaveState({ + status: "error", + message: asErrorMessage(error, fallback), + }); + } + }); + }, + [api], + ); + + const applyGatewaySettings = useCallback( + (payload: GatewaySettingsSyncPayload) => { + // Automation snapshots ride along on the settings-sync channel but are + // desktop-owned state with their own revision — feed them straight into + // the automation store instead of the settings state. + const automation = payload as { + automationCron?: CronSnapshot; + automationHooks?: HooksSnapshot; + }; + if (automation.automationCron) { + feedCronSnapshot(automation.automationCron); + } + if (automation.automationHooks) { + feedHooksSnapshot(automation.automationHooks); + } + const prev = settingsRef.current; + const rawNext = resolveAppWorkspaceProjects(applyGatewaySettingsSyncPayload(prev, payload)); + const next = redactSettingsForWebStorage(rawNext); + if (!hasSettingsSyncChanged(prev, next)) { + return; + } + settingsRef.current = next; + setSettingsState(next); + queueSettingsSave(prev, next, "同步桌面端设置失败。", false); + }, + [queueSettingsSave], + ); + + const setSettings = useCallback( + (updater: (prev: AppSettings) => AppSettings) => { + const prev = settingsRef.current; + const updated = updater(prev); + if (updated === prev) return; + const rawNext = resolveAppWorkspaceProjects(normalizeSettings(updated)); + const next = redactSettingsForWebStorage(rawNext); + settingsRef.current = next; + setSettingsState(next); + queueSettingsSave(prev, rawNext, "保存 WebUI 设置失败。", true); + }, + [queueSettingsSave], + ); + + useEffect(() => { + if (!api) { + setSettingsSyncReady(token.trim() === ""); + setSettingsSyncError(null); + return; + } + + let cancelled = false; + setSettingsSyncReady(false); + setSettingsSyncError(null); + // Best-effort: the desktop may be offline; the settings-sync push + // populates the store once it connects. + void initAutomation().catch(() => undefined); + const unsubscribe = api.subscribeSettings((payload) => { + if (cancelled) { + return; + } + applyGatewaySettings(payload); + setSettingsSyncError(null); + }); + + void api + .getSettings() + .then((payload) => { + if (!cancelled) { + applyGatewaySettings(payload); + setSettingsSyncReady(true); + setSettingsSyncError(null); + } + }) + .catch((error) => { + if (!cancelled) { + setSettingsSyncError(asErrorMessage(error, "同步桌面端设置失败")); + setSettingsSyncReady(true); + } + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [api, activeAgentId, applyGatewaySettings, token]); + + return { + settings, + setSettings, + settingsSyncReady, + settingsSyncError, + settingsSaveState, + }; +} diff --git a/crates/agent-mobile/src/app/hooks/usePendingUploads.ts b/crates/agent-mobile/src/app/hooks/usePendingUploads.ts new file mode 100644 index 000000000..f994c8806 --- /dev/null +++ b/crates/agent-mobile/src/app/hooks/usePendingUploads.ts @@ -0,0 +1,392 @@ +import type { MentionComposerHandle } from "@liveagent/ui/components/chat/MentionComposer"; +import type { NotifyItem } from "@liveagent/ui/components/chat/NotifyToast"; +import { t as translate } from "@liveagent/ui/i18n/index"; +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { mergePendingUploadedFiles } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { registerLocalUploadedImagePreviews } from "@liveagent/ui/lib/chat/uploadedImagePreview"; +import { type DragEvent, type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { + clipboardHasFileSignal, + extractClipboardFiles, + readClipboardFiles, +} from "@/lib/clipboardFiles"; +import type { AppSettings } from "@/lib/settings"; +import { importReadableFiles } from "@/lib/uploadReadableFiles"; + +import { asErrorMessage } from "../chatEventUtils"; +import { MAX_UPLOAD_FILES } from "../constants"; +import { dragEventHasFiles } from "../domUtils"; +import { formatTranslation } from "../historyUtils"; + +type UsePendingUploadsParams = { + token: string; + resolveAgentID: () => Promise; + historyShareToken: string | null; + settingsSyncReady: boolean; + settingsOpen: boolean; + activeView: "chat" | "skills-hub" | "mcp-hub"; + locale: AppSettings["locale"]; + executionMode: AppSettings["system"]["executionMode"]; + conversationId: string; + selectedHistoryId: string; + displayedConversationWorkdirRef: RefObject; + composerRef: RefObject; + // Upload feedback goes to the top-right toast stack, never into the + // transcript area — a failed upload is not conversation output. + addNotify: (type: NotifyItem["type"], message: string) => void; +}; + +export function usePendingUploads(params: UsePendingUploadsParams) { + const { + token, + resolveAgentID, + historyShareToken, + settingsSyncReady, + settingsOpen, + activeView, + locale, + executionMode, + conversationId, + selectedHistoryId, + displayedConversationWorkdirRef, + composerRef, + addNotify, + } = params; + + const [pendingUploadedFiles, setPendingUploadedFiles] = useState([]); + const [isUploadingFiles, setIsUploadingFiles] = useState(false); + const [isFileDropActive, setIsFileDropActive] = useState(false); + const fileInputRef = useRef(null); + const pendingUploadedFilesRef = useRef(pendingUploadedFiles); + const pendingUploadsByConversationRef = useRef>(new Map()); + const isUploadingFilesRef = useRef(isUploadingFiles); + const uploadDragDepthRef = useRef(0); + const displayedConversationIdRef = useRef(""); + // Render-assigned mirror: an in-flight import settling between a render and + // its effects must still see the latest mode when it decides whether its + // result is stale. + const executionModeRef = useRef(executionMode); + executionModeRef.current = executionMode; + + const displayedConversationId = (selectedHistoryId || conversationId).trim(); + displayedConversationIdRef.current = displayedConversationId; + + const setUploadingFiles = useCallback((active: boolean) => { + isUploadingFilesRef.current = active; + setIsUploadingFiles(active); + }, []); + + const isDisplayedConversation = useCallback((targetConversationId: string) => { + const conversationIdValue = targetConversationId.trim(); + return conversationIdValue !== "" && displayedConversationIdRef.current === conversationIdValue; + }, []); + + const getPendingUploadsForConversation = useCallback( + (targetConversationId: string) => { + const conversationIdValue = targetConversationId.trim(); + if (!conversationIdValue || isDisplayedConversation(conversationIdValue)) { + return pendingUploadedFilesRef.current; + } + return pendingUploadsByConversationRef.current.get(conversationIdValue) ?? []; + }, + [isDisplayedConversation], + ); + + const setPendingUploadsForConversation = useCallback( + (targetConversationId: string, nextFiles: PendingUploadedFile[]) => { + const conversationIdValue = targetConversationId.trim(); + const normalizedFiles = nextFiles.slice(); + if (conversationIdValue) { + if (normalizedFiles.length > 0) { + pendingUploadsByConversationRef.current.set(conversationIdValue, normalizedFiles); + } else { + pendingUploadsByConversationRef.current.delete(conversationIdValue); + } + } + if (!conversationIdValue || isDisplayedConversation(conversationIdValue)) { + pendingUploadedFilesRef.current = normalizedFiles; + setPendingUploadedFiles(normalizedFiles); + } + }, + [isDisplayedConversation], + ); + + const updatePendingUploadsForConversation = useCallback( + ( + targetConversationId: string, + updater: (current: PendingUploadedFile[]) => PendingUploadedFile[], + ) => { + const conversationIdValue = targetConversationId.trim(); + const currentFiles = getPendingUploadsForConversation(conversationIdValue); + const nextFiles = updater(currentFiles); + setPendingUploadsForConversation(conversationIdValue, nextFiles); + return nextFiles; + }, + [getPendingUploadsForConversation, setPendingUploadsForConversation], + ); + + // A draft conversation got its real id: re-key its stored uploads without + // touching the rendered state — the displayed id flips to `nextId` in the + // same commit, so the switch effect below re-reads the moved entry. + const moveConversationUploads = useCallback((previousId: string, nextId: string) => { + const previous = previousId.trim(); + const next = nextId.trim(); + if (!previous || !next || previous === next) { + return; + } + const files = pendingUploadsByConversationRef.current.get(previous); + if (files === undefined) { + return; + } + pendingUploadsByConversationRef.current.delete(previous); + pendingUploadsByConversationRef.current.set(next, files); + }, []); + + const clearPendingUploads = useCallback(() => { + pendingUploadedFilesRef.current = []; + pendingUploadsByConversationRef.current.clear(); + isUploadingFilesRef.current = false; + uploadDragDepthRef.current = 0; + setPendingUploadedFiles([]); + setIsUploadingFiles(false); + setIsFileDropActive(false); + }, []); + + useEffect(() => { + const nextFiles = displayedConversationId + ? (pendingUploadsByConversationRef.current.get(displayedConversationId) ?? []) + : []; + pendingUploadedFilesRef.current = nextFiles; + setPendingUploadedFiles(nextFiles); + }, [displayedConversationId]); + + const handleImportReadableFiles = useCallback( + async (filesToImport: File[]) => { + if (filesToImport.length === 0) { + return; + } + if (isUploadingFilesRef.current) { + addNotify("warning", translate("chat.upload.uploading", locale)); + return; + } + if (executionMode === "text") { + addNotify("warning", translate("chat.upload.onlyInTools", locale)); + return; + } + const workdir = displayedConversationWorkdirRef.current.trim(); + if (!workdir) { + addNotify("warning", translate("chat.upload.requireWorkdir", locale)); + return; + } + const targetConversationId = displayedConversationIdRef.current; + if (!targetConversationId) { + addNotify("warning", "请先选择或创建会话后再上传文件。"); + return; + } + + const currentUploads = getPendingUploadsForConversation(targetConversationId); + setPendingUploadsForConversation(targetConversationId, currentUploads); + const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - currentUploads.length); + if (remainingFileSlots === 0) { + addNotify( + "warning", + formatTranslation(translate("chat.upload.maxFilesIgnored", locale), { + max: MAX_UPLOAD_FILES, + count: filesToImport.length, + }), + ); + return; + } + + const importBatch = filesToImport.slice(0, remainingFileSlots); + const ignoredForLimit = filesToImport.length - importBatch.length; + setUploadingFiles(true); + try { + const agentID = await resolveAgentID(); + const result = await importReadableFiles(token, agentID, workdir, importBatch); + // An import that settles after its upload context was invalidated + // must not resurrect cleared attachments: files picked inside the + // old workspace are not readable from the new one. + if ( + (await resolveAgentID()) !== agentID || + executionModeRef.current === "text" || + (isDisplayedConversation(targetConversationId) && + displayedConversationWorkdirRef.current.trim() !== workdir) + ) { + addNotify("warning", "上传目标已失效,已忽略本次导入的文件"); + return; + } + registerLocalUploadedImagePreviews({ + workspaceRoot: workdir, + uploadedFiles: result.files, + sourceFiles: importBatch, + }); + + if (result.files.length > 0) { + updatePendingUploadsForConversation(targetConversationId, (current) => + mergePendingUploadedFiles(current, result.files).slice(0, MAX_UPLOAD_FILES), + ); + if (isDisplayedConversation(targetConversationId)) { + composerRef.current?.focus(); + } + } + + if (result.files.length === 0 && result.skipped.length > 0) { + addNotify("error", `所选文件均无法导入:\n${result.skipped.join("\n")}`); + } else if (result.skipped.length > 0) { + addNotify("warning", `以下文件已跳过:\n${result.skipped.join("\n")}`); + } + if (ignoredForLimit > 0) { + addNotify( + "warning", + formatTranslation(translate("chat.upload.maxFilesIgnored", locale), { + max: MAX_UPLOAD_FILES, + count: ignoredForLimit, + }), + ); + } + } catch (error) { + addNotify("error", asErrorMessage(error, "导入文件失败")); + } finally { + setUploadingFiles(false); + } + }, + [ + addNotify, + composerRef, + displayedConversationWorkdirRef, + executionMode, + getPendingUploadsForConversation, + isDisplayedConversation, + locale, + resolveAgentID, + setPendingUploadsForConversation, + setUploadingFiles, + token, + updatePendingUploadsForConversation, + ], + ); + + useEffect(() => { + if ( + !token || + historyShareToken || + !settingsSyncReady || + settingsOpen || + activeView !== "chat" + ) { + return; + } + + const handleDocumentPaste = (event: globalThis.ClipboardEvent) => { + if (event.defaultPrevented) return; + const clipboardFiles = extractClipboardFiles(event.clipboardData); + if (clipboardFiles.length > 0) { + event.preventDefault(); + event.stopPropagation(); + void handleImportReadableFiles(clipboardFiles); + return; + } + if (!clipboardHasFileSignal(event.clipboardData)) return; + + event.preventDefault(); + event.stopPropagation(); + void readClipboardFiles() + .then((files) => { + if (files.length === 0) { + addNotify("warning", "无法读取剪贴板中的文件,请尝试拖拽或点击上传。"); + return; + } + return handleImportReadableFiles(files); + }) + .catch((error) => { + addNotify("error", asErrorMessage(error, "读取剪贴板文件失败")); + }); + }; + + document.addEventListener("paste", handleDocumentPaste, true); + return () => { + document.removeEventListener("paste", handleDocumentPaste, true); + }; + }, [ + activeView, + addNotify, + handleImportReadableFiles, + historyShareToken, + settingsOpen, + settingsSyncReady, + token, + ]); + + const handleFileDragEnter = useCallback((event: DragEvent) => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + uploadDragDepthRef.current += 1; + setIsFileDropActive(true); + }, []); + + const handleFileDragOver = useCallback( + (event: DragEvent, canDropUpload: boolean) => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = canDropUpload ? "copy" : "none"; + setIsFileDropActive(true); + }, + [], + ); + + const handleFileDragLeave = useCallback((event: DragEvent) => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + uploadDragDepthRef.current = Math.max(0, uploadDragDepthRef.current - 1); + if (uploadDragDepthRef.current === 0) { + setIsFileDropActive(false); + } + }, []); + + const handleFileDrop = useCallback( + ( + event: DragEvent, + options: { + canDropUpload: boolean; + disabledMessage: string; + }, + ) => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + uploadDragDepthRef.current = 0; + setIsFileDropActive(false); + + const files = Array.from(event.dataTransfer.files ?? []); + if (files.length === 0) return; + if (!options.canDropUpload) { + addNotify("warning", options.disabledMessage); + return; + } + void handleImportReadableFiles(files); + }, + [addNotify, handleImportReadableFiles], + ); + + return { + pendingUploadedFiles, + isUploadingFiles, + isFileDropActive, + fileInputRef, + setUploadingFiles, + getPendingUploadsForConversation, + setPendingUploadsForConversation, + updatePendingUploadsForConversation, + moveConversationUploads, + clearPendingUploads, + handleImportReadableFiles, + handleFileDragEnter, + handleFileDragOver, + handleFileDragLeave, + handleFileDrop, + }; +} diff --git a/crates/agent-mobile/src/app/hooks/useProjectToolsRuntime.ts b/crates/agent-mobile/src/app/hooks/useProjectToolsRuntime.ts new file mode 100644 index 000000000..0d2ee841c --- /dev/null +++ b/crates/agent-mobile/src/app/hooks/useProjectToolsRuntime.ts @@ -0,0 +1,187 @@ +import { useWorkspaceOverlays } from "@liveagent/ui/components/workspace-editor/useWorkspaceOverlays"; +import { + applyTerminalEventToSessions, + replaceTerminalSessionsForProject, + sortTerminalSessions, + terminalSessionBelongsToProject, +} from "@liveagent/ui/lib/terminal/sessionStore"; +import type { TerminalClient, TerminalSession } from "@liveagent/ui/lib/terminal/types"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type UseProjectToolsRuntimeParams = { + terminalClient: TerminalClient | null; + settingsSyncReady: boolean; + isAgentMode: boolean; + webTerminalSessionsEnabled: boolean; + statusOnline?: boolean; + statusSessionId?: string | null; + terminalProjectPath: string; + terminalProjectPathKey: string; + rightDockFileTreeOpen: boolean; + rightDockSshTunnelOpen: boolean; +}; + +export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { + const { + terminalClient, + settingsSyncReady, + isAgentMode, + webTerminalSessionsEnabled, + statusOnline, + statusSessionId, + terminalProjectPath, + terminalProjectPathKey, + rightDockFileTreeOpen, + rightDockSshTunnelOpen, + } = params; + + const [terminalSessions, setTerminalSessions] = useState([]); + const [terminalSessionsLoaded, setTerminalSessionsLoaded] = useState(false); + const terminalSessionsVersionRef = useRef(0); + const terminalStatusSessionIdRef = useRef(""); + + const workspaceOverlays = useWorkspaceOverlays({ + terminalProjectPath, + terminalProjectPathKey, + rightDockFileTreeOpen, + }); + + const projectTerminalSessions = useMemo( + () => + terminalProjectPathKey + ? terminalSessions.filter((session) => + terminalSessionBelongsToProject(session, terminalProjectPathKey), + ) + : [], + [terminalProjectPathKey, terminalSessions], + ); + + const handleProjectTerminalSessionsChange = useCallback((sessions: TerminalSession[]) => { + terminalSessionsVersionRef.current += 1; + setTerminalSessions(sortTerminalSessions(sessions)); + }, []); + + useEffect(() => { + // Loaded flips false whenever the gates or the gateway session identity + // change, and true only once list() settles below — RightDockPanel uses it + // to defer terminal-tab GC until the session list is actually known. + setTerminalSessionsLoaded(false); + if (!terminalClient) { + terminalSessionsVersionRef.current += 1; + setTerminalSessions([]); + return; + } + if (!settingsSyncReady) { + return; + } + if (!isAgentMode || !webTerminalSessionsEnabled || statusOnline === false) { + terminalSessionsVersionRef.current += 1; + setTerminalSessions([]); + return; + } + if (statusOnline !== true) { + return; + } + const normalizedStatusSessionId = statusSessionId?.trim() ?? ""; + if ( + normalizedStatusSessionId && + terminalStatusSessionIdRef.current !== normalizedStatusSessionId + ) { + const hadPreviousSession = terminalStatusSessionIdRef.current !== ""; + terminalStatusSessionIdRef.current = normalizedStatusSessionId; + if (hadPreviousSession) { + terminalSessionsVersionRef.current += 1; + setTerminalSessions([]); + } + } + let cancelled = false; + const requestVersion = terminalSessionsVersionRef.current; + void terminalClient + .list() + .then((sessions) => { + if (!cancelled && terminalSessionsVersionRef.current === requestVersion) { + setTerminalSessions(sortTerminalSessions(sessions)); + } + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) { + setTerminalSessionsLoaded(true); + } + }); + return () => { + cancelled = true; + }; + }, [ + isAgentMode, + settingsSyncReady, + statusOnline, + statusSessionId, + terminalClient, + webTerminalSessionsEnabled, + ]); + + useEffect(() => { + if (!terminalClient) return; + return terminalClient.subscribe((event) => { + if (event.kind === "output") return; + terminalSessionsVersionRef.current += 1; + setTerminalSessions((current) => applyTerminalEventToSessions(current, event)); + }); + }, [terminalClient]); + + useEffect(() => { + // One catch-up fetch when the ssh-tunnel tab becomes usable (opened, agent + // back online, gateway session ready). Ongoing freshness is event-driven: + // the terminalClient.subscribe effect above applies created/updated/closed + // broadcasts, and SshTunnelPanel's own active-gated reconcile feeds + // list() results back through onSessionsReconcile -> onSessionsChange. + // A parallel 5s poll here only duplicated that traffic. + if (!terminalClient) return; + if (!settingsSyncReady) return; + if (!isAgentMode || !webTerminalSessionsEnabled || statusOnline !== true) return; + if (!rightDockSshTunnelOpen || !terminalProjectPathKey) return; + + let cancelled = false; + void terminalClient + .list(terminalProjectPathKey) + .then((sessions) => { + if (cancelled) return; + terminalSessionsVersionRef.current += 1; + setTerminalSessions((current) => + replaceTerminalSessionsForProject(current, terminalProjectPathKey, sessions), + ); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [ + isAgentMode, + rightDockSshTunnelOpen, + settingsSyncReady, + statusOnline, + terminalClient, + terminalProjectPathKey, + webTerminalSessionsEnabled, + ]); + + const resetTerminalSessions = useCallback(() => { + terminalSessionsVersionRef.current += 1; + terminalStatusSessionIdRef.current = ""; + setTerminalSessions([]); + setTerminalSessionsLoaded(false); + }, []); + + return { + ...workspaceOverlays, + terminalSessions, + terminalSessionsLoaded, + setTerminalSessions, + terminalSessionsVersionRef, + terminalStatusSessionIdRef, + projectTerminalSessions, + handleProjectTerminalSessionsChange, + resetTerminalSessions, + }; +} diff --git a/crates/agent-mobile/src/app/sidebar/GatewaySidebarContainer.tsx b/crates/agent-mobile/src/app/sidebar/GatewaySidebarContainer.tsx new file mode 100644 index 000000000..1d4ab63fe --- /dev/null +++ b/crates/agent-mobile/src/app/sidebar/GatewaySidebarContainer.tsx @@ -0,0 +1,350 @@ +// Sidebar container for the web end: owns every useSidebarSelector +// subscription plus the rename UI state, so store commits (activity ticks, +// list updates, per-row mutations) re-render this subtree only — never +// GatewayApp. Renders the per-end view. + +import { + buildChatHistorySidebarBaseProps, + buildChatHistorySidebarConversationProps, + buildChatHistorySidebarWorkspaceProps, + ChatHistorySidebar, + type ChatHistorySidebarContainerSource, +} from "@liveagent/ui/components/chat/ChatHistorySidebar"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import type { SidebarBatchDeleteOptions } from "@liveagent/ui/lib/sidebar/batchDelete"; +import { deleteSidebarConversations } from "@liveagent/ui/lib/sidebar/batchDelete"; +import type { SidebarSnapshot, SidebarStore } from "@liveagent/ui/lib/sidebar/store"; +import type { TransientSidebarRunningConversation } from "@liveagent/ui/lib/sidebar/transientActivity"; +import { mergeTransientSidebarRunningActivity } from "@liveagent/ui/lib/sidebar/transientActivity"; +import type { SidebarErrorCode } from "@liveagent/ui/lib/sidebar/types"; +import { useSidebarContainerState } from "@liveagent/ui/lib/sidebar/useSidebarContainerState"; +import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; +import { sortWorkspaceProjectsByActivity } from "@liveagent/ui/lib/workspaceProjects"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; + +function selectConversationIndex(snapshot: SidebarSnapshot) { + return snapshot.byId; +} + +// Transport-shaped list errors merely restate "the read path is down or +// congested right now"; the page-level banner and Online/Offline pill own +// that story, so the sidebar never repeats it — including the stale copy +// that lingers until the next reconcile tick or reconnect refetch lands. +// Three sources produce this class of message: +// - browser⇄gateway socket failures (mirrors isRecoverableGatewayTransportError +// in lib/gatewaySocket.ts) plus the client-side request timeout, which the +// status poll ignores under fresh inbound activity for the same reason; +// - the Go hub rejecting a roundtrip while the desktop agent is briefly +// offline or re-registering ("agent offline", websocket_roundtrip.go) — +// the socket stays up in that window, so connectionLost never covers it; +// - gateway-side context outcomes on the hub⇄agent roundtrip +// ("request timed out"/"request canceled", websocket_roundtrip.go). +// Genuine desktop read failures arrive as other strings and still surface. +function isGatewayTransportErrorDetail(detail: string | null | undefined) { + const message = (detail ?? "").trim(); + return ( + message.startsWith("Gateway WebSocket disconnected") || + message === "Gateway WebSocket is not connected" || + message.startsWith("Gateway transport stalled") || + message.startsWith("Gateway WebSocket request timed out") || + message === "agent offline" || + message === "request timed out" || + message === "request canceled" + ); +} + +// Stable identity wrapper so callback props from GatewayApp (recreated per +// render) never churn effects or the memo'd view rows. +function useStableCallback( + handler: (...args: Args) => Return, +): (...args: Args) => Return { + const handlerRef = useRef(handler); + handlerRef.current = handler; + return useCallback((...args: Args) => handlerRef.current(...args), []); +} + +export type GatewaySidebarContainerProps = Omit< + ChatHistorySidebarContainerSource, + "onShareConversation" +> & { + store: SidebarStore; + // 手动压缩 pending 已按会话 id 键化,多个会话可同时“转圈”(issue #359 缺陷 #3)。 + transientRunningConversations?: readonly TransientSidebarRunningConversation[]; + // GatewayApp-level sidebar errors (project removal flow); store errors are + // derived locally and take precedence. + externalErrorMessage: string | null; + // Gateway socket dropped after having been connected: transport-shaped + // error cards are suppressed because the page banner owns that messaging. + connectionLost: boolean; + // Workspace and recent-conversation interactions are available only while + // both the browser transport and the desktop Agent are confirmed online. + sectionsDisabled: boolean; + isLocalDraftConversationId: (id: string) => boolean; + onShareConversation: (item: ChatHistorySummary) => void; + // User-initiated removal of a local draft row (never hits the backend). + onLocalDraftDeleted: (id: string) => void; + // Conversations that left the authoritative index (remote delete, local + // delete confirmation, reconcile drop): GatewayApp cleans caches and + // migrates the selection when the displayed conversation vanished. + onConversationsRemoved: (ids: readonly string[]) => void; +}; + +export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { + const { + store, + projects, + externalErrorMessage, + connectionLost, + sectionsDisabled, + isLocalDraftConversationId, + } = props; + const { t } = useLocale(); + + const { + items, + listState, + scopeKey, + runningConversationIds, + mutations, + mutationErrors, + projectActivityInputs, + } = useSidebarContainerState(store); + const conversationIndex = useSidebarSelector(store, selectConversationIndex); + const effectiveRunningActivity = useMemo( + () => + mergeTransientSidebarRunningActivity( + runningConversationIds, + projectActivityInputs.runningWorkdirPathKeys, + props.transientRunningConversations, + ), + [ + projectActivityInputs.runningWorkdirPathKeys, + props.transientRunningConversations, + runningConversationIds, + ], + ); + + // --- Rename UI state (moved out of GatewayApp) --------------------------- + const [renamingId, setRenamingId] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + + useEffect(() => { + if (!sectionsDisabled) { + return; + } + setRenamingId(null); + setRenameDraft(""); + }, [sectionsDisabled]); + + const clearMutationErrors = useCallback(() => { + for (const id of store.getSnapshot().mutationErrors.keys()) { + store.clearMutationError(id); + } + }, [store]); + + const handleStartRenaming = useStableCallback((item: ChatHistorySummary) => { + if (sectionsDisabled) { + return; + } + setRenamingId(item.id); + setRenameDraft(item.title); + }); + + const handleCommitRename = useStableCallback(() => { + if (sectionsDisabled) { + setRenamingId(null); + setRenameDraft(""); + return; + } + if (!renamingId) { + return; + } + const conversationId = renamingId; + const title = renameDraft.trim(); + setRenamingId(null); + setRenameDraft(""); + if (!title || title === store.peek(conversationId)?.title) { + return; + } + clearMutationErrors(); + void store.rename(conversationId, title); + }); + + const handleCancelRename = useStableCallback(() => { + setRenamingId(null); + setRenameDraft(""); + }); + + const handleSetPinned = useStableCallback((id: string, isPinned: boolean) => { + if (sectionsDisabled) { + return; + } + clearMutationErrors(); + void store.setPinned(id, isPinned); + }); + + const handleMoveToWorkspace = useStableCallback((id: string, cwd: string) => { + if (sectionsDisabled) { + return; + } + clearMutationErrors(); + void store.setCwd(id, cwd); + }); + + const handleMoveConversationsToWorkspace = useStableCallback( + async (ids: readonly string[], cwd: string) => { + if (sectionsDisabled) { + return ids; + } + clearMutationErrors(); + const results = await Promise.all( + ids.map(async (id) => ({ id, moved: await store.setCwd(id, cwd) })), + ); + return results.filter((result) => !result.moved).map((result) => result.id); + }, + ); + + const handleDeleteConversation = useStableCallback((id: string) => { + if (sectionsDisabled) { + return; + } + clearMutationErrors(); + const existing = store.peek(id); + if (existing?.isPending === true || isLocalDraftConversationId(id)) { + store.removeLocal(id); + props.onLocalDraftDeleted(id); + return; + } + void store.remove(id); + }); + + const handleDeleteConversations = useStableCallback( + async (ids: readonly string[], options?: SidebarBatchDeleteOptions) => { + if (sectionsDisabled) { + return { deletedIds: [], failedIds: [...ids], skippedIds: [] }; + } + clearMutationErrors(); + return deleteSidebarConversations( + ids, + async (id) => { + const existing = store.peek(id); + if (existing?.isPending === true || isLocalDraftConversationId(id)) { + store.removeLocal(id); + props.onLocalDraftDeleted(id); + return true; + } + return store.remove(id); + }, + options, + ); + }, + ); + + const handleLoadMore = useStableCallback(() => { + if (sectionsDisabled) { + return; + } + void store.loadMore(); + }); + + // --- Authoritative-removal watcher --------------------------------------- + // byId is the cross-scope index: entries only leave it on delete events, + // confirmed local deletes, or authoritative reconcile drops — a scope + // switch does not evict, so this never fires for out-of-scope selections. + const onConversationsRemoved = useStableCallback(props.onConversationsRemoved); + const knownConversationIdsRef = useRef | null>(null); + useEffect(() => { + const previous = knownConversationIdsRef.current; + const next = new Set(conversationIndex.keys()); + knownConversationIdsRef.current = next; + if (!previous || previous.size === 0) { + return; + } + const removed: string[] = []; + for (const id of previous) { + if (!next.has(id)) { + removed.push(id); + } + } + if (removed.length > 0) { + onConversationsRemoved(removed); + } + }, [conversationIndex, onConversationsRemoved]); + + // --- Errors --------------------------------------------------------------- + const translateErrorCode = useCallback( + (code: SidebarErrorCode) => t(`chat.history.${code}`), + [t], + ); + const listErrorMessage = useMemo(() => { + if (connectionLost) { + return null; + } + if (listState.error && !isGatewayTransportErrorDetail(listState.errorDetail)) { + return listState.errorDetail?.trim() || translateErrorCode(listState.error); + } + return null; + }, [connectionLost, listState.error, listState.errorDetail, translateErrorCode]); + const actionErrorMessage = useMemo(() => { + if (connectionLost) { + return null; + } + let lastMutationError: SidebarErrorCode | null = null; + for (const code of mutationErrors.values()) { + lastMutationError = code; + } + if (lastMutationError) { + return translateErrorCode(lastMutationError); + } + return externalErrorMessage; + }, [connectionLost, externalErrorMessage, mutationErrors, translateErrorCode]); + + // --- Projects ------------------------------------------------------------- + const sortedProjects = useMemo( + () => + sortWorkspaceProjectsByActivity(projects, { + projectActivityUpdatedAts: projectActivityInputs.workdirActivity, + runningProjectPathKeys: effectiveRunningActivity.runningProjectPathKeys, + }), + [ + effectiveRunningActivity.runningProjectPathKeys, + projectActivityInputs.workdirActivity, + projects, + ], + ); + + return ( + + ); +} diff --git a/crates/agent-mobile/src/app/sidebar/gatewaySidebarAvailability.ts b/crates/agent-mobile/src/app/sidebar/gatewaySidebarAvailability.ts new file mode 100644 index 000000000..97ad4af3c --- /dev/null +++ b/crates/agent-mobile/src/app/sidebar/gatewaySidebarAvailability.ts @@ -0,0 +1,39 @@ +export type GatewaySidebarStatusFreshnessState = { + socketConnected: boolean; + agentStatusFresh: boolean; +}; + +export type GatewaySidebarStatusFreshnessEvent = + | { type: "connection"; connected: boolean } + | { type: "status" }; + +export const INITIAL_GATEWAY_SIDEBAR_STATUS_FRESHNESS: GatewaySidebarStatusFreshnessState = { + socketConnected: false, + agentStatusFresh: false, +}; + +export function reduceGatewaySidebarStatusFreshness( + state: GatewaySidebarStatusFreshnessState, + event: GatewaySidebarStatusFreshnessEvent, +): GatewaySidebarStatusFreshnessState { + if (event.type === "connection") { + return { + socketConnected: event.connected, + // Every authenticated socket starts a new status epoch. The previous + // socket's cached online verdict cannot make the new path interactive. + agentStatusFresh: false, + }; + } + return { + ...state, + agentStatusFresh: state.socketConnected, + }; +} + +export function shouldDisableGatewaySidebarSections(input: { + connectionLost: boolean; + agentStatusFresh: boolean; + agentOnline: boolean | null | undefined; +}): boolean { + return input.connectionLost || !input.agentStatusFresh || input.agentOnline !== true; +} diff --git a/crates/agent-mobile/src/app/types.ts b/crates/agent-mobile/src/app/types.ts new file mode 100644 index 000000000..bdf98b37f --- /dev/null +++ b/crates/agent-mobile/src/app/types.ts @@ -0,0 +1,29 @@ +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import type { ChatCommandOutcome } from "@/lib/chat/stream/chatCommandPipeline"; +import type { ChatRuntimeControls, CustomProvider } from "@/lib/settings"; + +export type SendChatOptions = { + conversationId?: string; + clientRequestId?: string; + uploadedFiles?: PendingUploadedFile[]; + runtimeControls?: ChatRuntimeControls; + workdir?: string; + editMessageRef?: HistoryMessageRef; + queuePolicy?: "auto" | "append" | "interrupt"; + // false for queue-destined sends: no transcript echo, the queue panel owns + // the prompt until it actually runs. + optimisticEcho?: boolean; +}; + +export type SendChatFn = ( + message: string, + options?: SendChatOptions, +) => Promise; + +export type ModelProviderSource = Pick; + +export type TunnelManagerToolChange = { + action: "create" | "close"; + projectPathKey: string; +}; diff --git a/crates/agent-mobile/src/components/GatewayTranscript.tsx b/crates/agent-mobile/src/components/GatewayTranscript.tsx new file mode 100644 index 000000000..70d44db3f --- /dev/null +++ b/crates/agent-mobile/src/components/GatewayTranscript.tsx @@ -0,0 +1,1090 @@ +import { + AssistantAvatar, + AssistantBubble, + LiveAssistantStatus, +} from "@liveagent/ui/components/chat/AssistantBubble"; +import { ChatEmptyState } from "@liveagent/ui/components/chat/ChatEmptyState"; +import { ContextCheckpointCard } from "@liveagent/ui/components/chat/ContextCheckpointCard"; +import { EditableUserMessageBubble } from "@liveagent/ui/components/chat/EditableUserMessageBubble"; +import { RetryDetailsBlock } from "@liveagent/ui/components/chat/RetryDetailsBlock"; +import { + TranscriptAssistantMessageActions, + TranscriptUserMessageActions, +} from "@liveagent/ui/components/chat/TranscriptMessageActions"; +import { UserAttachmentCards } from "@liveagent/ui/components/chat/UserAttachmentCards"; +import { Loader2 } from "@liveagent/ui/components/IconSet"; +import { useLocale } from "@liveagent/ui/i18n/LocaleContext"; +import { normalizeLiveToolStatus, VIBING_STATUS } from "@liveagent/ui/lib/chat/assistantStatus"; +import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; +import { + type PendingUploadedFile, + splitUserAttachmentsForDisplay, +} from "@liveagent/ui/lib/chat/uploadedFiles"; +import type { UploadedImagePreviewLoader } from "@liveagent/ui/lib/chat/uploadedImagePreview"; +import { useCommitDetailsLoader } from "@liveagent/ui/lib/chat/useCommitDetailsLoader"; +import { + type CommitDetailsLoader, + UserMessageContent, +} from "@liveagent/ui/lib/chat/userMessageContent"; +import type { GitClient } from "@liveagent/ui/lib/git/types"; +import { createLiveRowScrollAdjustPolicy } from "@liveagent/ui/lib/transcript-virtual/liveScrollAdjustPolicy"; +import { + buildTranscriptLayoutKey, + createTranscriptMeasurementsLru, +} from "@liveagent/ui/lib/transcript-virtual/measurementsLru"; +import { + CHECKPOINT_ROW_ESTIMATE_PX, + estimateAssistantRowHeight, + estimateUserRowHeight, + measureEstimateText, +} from "@liveagent/ui/lib/transcript-virtual/rowEstimates"; +import { + type TranscriptNavigationHandle, + useTranscriptNavigation, +} from "@liveagent/ui/lib/transcript-virtual/useTranscriptNavigation"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + type Dispatch, + type MutableRefObject, + memo, + type SetStateAction, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import { getRoundText } from "@/lib/chat/uiMessages"; +import { DEFAULT_CHAT_TRANSCRIPT_WIDTH } from "@/lib/settings"; +import { extractLiveRange } from "@/lib/transcript-virtual/liveRangeExtractor"; +import type { RetryAttemptRecord, TranscriptRow } from "../lib/chat/transcript/types"; +import type { SectionId } from "../pages/settings/types"; + +type GatewayTranscriptProps = { + conversationId?: string; + // The whole transcript as one row list, rendered by one virtualizer. Rows + // come from one store assembly, so a row can never render twice. + rows: readonly TranscriptRow[]; + // Index of the first unfolded-turn row (-1 when everything is folded); + // rows at or after it are force-mounted so a streaming reply never + // unmounts mid-run. + liveStartIndex?: number; + // Key of the actively streaming turn (caret / live structural state). + activeTurnKey?: string | null; + contentWidth?: number; + // Whether the scroll-follow engine is attached to the bottom; gates the + // virtualizer's resize-compensation carve-out for live-row growth. + isViewportFollowing?: () => boolean; + viewportFollowing?: boolean; + // Imperative jump handle for the floor navigation rail. + navRef?: MutableRefObject; + // Reports the user row at the viewport's top edge (the "current floor"). + onAnchorUserRowChange?: (rowKey: string | null) => void; + error?: string | null; + toolStatus?: string | null; + toolStatusIsCompaction?: boolean; + // Live run's stream-retry history; renders as an expandable details block + // under the live status (mirrors the desktop app). + retryAttempts?: readonly RetryAttemptRecord[]; + isStreaming?: boolean; + isLoading?: boolean; + loadingTitle?: string; + hasModels?: boolean; + onOpenSettings?: (section?: SectionId) => void; + hasMoreHistory?: boolean; + isLoadingMoreHistory?: boolean; + onLoadEarlierHistory?: () => void; + showUsage?: boolean; + usageContextWindow?: number; + workspaceRoot?: string; + gitClient?: GitClient | null; + onOpenFileLink?: (link: ChatFileLink) => void; + onLoadUploadedImagePreview?: UploadedImagePreviewLoader; + onResendFromEdit?: ( + messageRef: HistoryMessageRef, + text: string, + uploadedFiles: PendingUploadedFile[], + ) => void; + onBranchConversation?: (messageRef: HistoryMessageRef) => void; + // Anchor messageId of the branch request in flight; the matching row shows + // a spinner and every branch button disables until it settles. + branchPendingMessageId?: string | null; + onSuggestionSelect?: (text: string) => void; + suggestionsDisabled?: boolean; + readOnly?: boolean; + redactToolContent?: boolean; +}; + +// Stream-born rows keep Streamdown's streaming render mode forever — even +// after their turn folds — so the streaming→static mode flip (and its full +// re-parse) can never happen. History-born rows render static from the +// start. +function rowRenderMode(row: Extract) { + return row.origin === "stream" ? ("streaming" as const) : ("static" as const); +} + +export type GatewayTranscriptNavHandle = TranscriptNavigationHandle; + +const TRANSCRIPT_ROW_ESTIMATED_HEIGHT = 260; +const TRANSCRIPT_ROW_GAP = 18; +const TRANSCRIPT_ROW_OVERSCAN_COUNT = 5; + +// Measured row heights survive conversation switches: saved on unmount, +// restored (width-gated) on the next open so the switch lays out with exact +// heights instead of estimates. +const transcriptMeasurementsLru = createTranscriptMeasurementsLru(); + +type GatewayTranscriptVirtualItem = + | { key: string; kind: "loadRemoteHistory" } + | { key: string; kind: "row"; row: TranscriptRow } + | { key: string; kind: "pendingBubble" }; + +function resolveNearestScrollViewport(element: HTMLElement | null) { + return element?.closest("[data-scroll-viewport]") as HTMLDivElement | null; +} + +function LiveStatusFooter(props: { status: string; isCompaction?: boolean }) { + const { status, isCompaction = false } = props; + return ( +
+ +
+ ); +} + +function HistoryLoadingState(props: { title?: string }) { + const title = props.title?.trim(); + return ( +
+
+
+
+ +
+
+ 正在加载会话历史 +
+ {title ? ( +
+ {title} +
+ ) : null} +
+
+
+ ); +} + +function CheckpointCard(props: { + item: Extract; + readOnly?: boolean; +}) { + const { item, readOnly = false } = props; + + return ( +
+ + ); +} + +function GatewayUserMessageBubbleBody(props: { + text: string; + attachments: PendingUploadedFile[]; + workspaceRoot?: string; + onLoadUploadedImagePreview?: UploadedImagePreviewLoader; + loadCommitDetails?: CommitDetailsLoader; +}) { + const { text, attachments, workspaceRoot, onLoadUploadedImagePreview, loadCommitDetails } = props; + const { visibleFiles, pastedTextFiles } = splitUserAttachmentsForDisplay(attachments, text); + + return ( +
+ + {text ? ( + + ) : null} +
+ ); +} + +// Shared user-row body: the bubble plus hover actions (copy / edit), or the +// inline editor while this row is being edited. Both transcript regions +// render it; the per-row copied/editing state lives in the owning region so +// folds and conversation switches reset it there. +// Memoized with per-row `isCopied`/`isEditing` booleans (instead of the raw +// region-level ids) so copying or editing one row never re-renders the +// others, and streaming flushes bail on every settled user row. +const GatewayUserMessageRowBody = memo(function GatewayUserMessageRowBody(props: { + row: Extract; + isStreaming: boolean; + readOnly?: boolean; + isCopied: boolean; + isEditing: boolean; + setCopiedMessageId: Dispatch>; + setEditingMessageId: Dispatch>; + workspaceRoot?: string; + onLoadUploadedImagePreview?: UploadedImagePreviewLoader; + loadCommitDetails?: CommitDetailsLoader; + onResendFromEdit?: ( + messageRef: HistoryMessageRef, + text: string, + uploadedFiles: PendingUploadedFile[], + ) => void; +}) { + const { + row, + isStreaming, + readOnly = false, + isCopied, + isEditing, + setCopiedMessageId, + setEditingMessageId, + workspaceRoot, + onLoadUploadedImagePreview, + loadCommitDetails, + onResendFromEdit, + } = props; + const { locale, t } = useLocale(); + const effectiveMessageRef = row.messageRef; + const missingStableRef = !effectiveMessageRef; + const editDisabled = readOnly || isStreaming || !onResendFromEdit || missingStableRef; + const editTitle = missingStableRef + ? locale === "en-US" + ? "This older message cannot be edited because it has no stable message identifier." + : "旧历史缺少稳定消息标识,无法编辑重发" + : t("chat.edit"); + + if (isEditing && effectiveMessageRef) { + return ( + setEditingMessageId(null)} + onSubmit={(text, attachments) => { + setEditingMessageId(null); + onResendFromEdit?.(effectiveMessageRef, text, attachments); + }} + /> + ); + } + + return ( +
+ + { + void navigator.clipboard.writeText(row.text).then(() => { + setCopiedMessageId(row.key); + window.setTimeout(() => { + setCopiedMessageId((current) => (current === row.key ? null : current)); + }, 1500); + }); + }} + editDisabled={editDisabled} + editTitle={editTitle} + onEdit={() => { + if (effectiveMessageRef) setEditingMessageId(row.key); + }} + readOnly={readOnly} + alwaysShowActions + /> +
+ ); +}); + +// Maps each assistant row to the nearest preceding user row — the prompt a +// retry re-sends. +function buildRetryTargetMap(rows: readonly TranscriptRow[]) { + const map = new Map>(); + let lastUser: Extract | null = null; + for (const row of rows) { + if (row.kind === "user") { + lastUser = row; + } else if (row.kind === "assistant" && lastUser) { + map.set(row.key, lastUser); + } + } + return map; +} + +// Shared assistant-row hover actions (copy / retry). Retry re-sends the +// nearest preceding user prompt through the edit-resend pipeline: this reply +// and everything after it are discarded, same as editing that prompt +// unchanged. Both transcript regions render it below the bubble. +const GatewayAssistantMessageActions = memo(function GatewayAssistantMessageActions(props: { + row: Extract; + retryTarget: Extract | null; + isStreaming: boolean; + isCopied: boolean; + setCopiedMessageId: Dispatch>; + onResendFromEdit?: ( + messageRef: HistoryMessageRef, + text: string, + uploadedFiles: PendingUploadedFile[], + ) => void; + onBranchConversation?: (messageRef: HistoryMessageRef) => void; + branchPendingMessageId?: string | null; +}) { + const { + row, + retryTarget, + isStreaming, + isCopied, + setCopiedMessageId, + onResendFromEdit, + onBranchConversation, + branchPendingMessageId, + } = props; + const { locale, t } = useLocale(); + const replyText = row.rounds + .map((round) => getRoundText(round).trim()) + .filter((text) => text.length > 0) + .join("\n\n"); + const retryMessageRef = retryTarget?.messageRef; + const retryDisabled = isStreaming || !onResendFromEdit || !retryMessageRef; + const retryTitle = retryMessageRef + ? t("chat.retry") + : locale === "en-US" + ? "This reply cannot be retried because its prompt has no stable message identifier." + : "旧历史缺少稳定消息标识,无法重试"; + const branchPending = branchPendingMessageId != null; + const isRowBranchPending = + branchPending && !!retryMessageRef && branchPendingMessageId === retryMessageRef.messageId; + const branchDisabled = isStreaming || !onBranchConversation || !retryMessageRef || branchPending; + const branchTitle = retryMessageRef ? t("chat.branch") : t("chat.branchUnavailable"); + + return ( + { + void navigator.clipboard.writeText(replyText).then(() => { + setCopiedMessageId(row.key); + window.setTimeout(() => { + setCopiedMessageId((current) => (current === row.key ? null : current)); + }, 1500); + }); + }} + retryDisabled={retryDisabled} + retryTitle={retryTitle} + onRetry={() => { + if (!retryTarget || !retryMessageRef) return; + onResendFromEdit?.(retryMessageRef, retryTarget.text, retryTarget.attachments); + }} + branchDisabled={branchDisabled} + branchTitle={branchTitle} + branchPending={isRowBranchPending} + onBranch={() => { + if (retryMessageRef) onBranchConversation?.(retryMessageRef); + }} + withAvatarSpacer + alwaysShowActions + /> + ); +}); + +const rowEstimateCache = new WeakMap(); + +// Content-shaped height estimates: only ever used for rows the virtualizer +// has never measured (the measurement cache is keyed by row key and survives +// folding), but a shaped guess keeps scroll corrections small while reading +// unmeasured history. +function estimateRowHeight(row: TranscriptRow): number { + const cached = rowEstimateCache.get(row); + if (cached !== undefined) { + return cached; + } + let estimate: number; + if (row.kind === "user") { + estimate = estimateUserRowHeight(row.text.length, row.attachments.length); + } else if (row.kind === "assistant") { + let proseChars = 0; + let codeLines = 0; + let codeFences = 0; + let toolCount = 0; + let thinkingCount = 0; + for (const round of row.rounds) { + for (const block of round.blocks) { + if (block.kind === "text") { + const measured = measureEstimateText(block.text); + proseChars += measured.proseChars; + codeLines += measured.codeLines; + codeFences += measured.codeFences; + } else if (block.kind === "thinking") { + thinkingCount += 1; + } else { + toolCount += 1; + } + } + } + estimate = estimateAssistantRowHeight({ + proseChars, + codeLines, + codeFences, + toolCount, + thinkingCount, + }); + } else if (row.kind === "checkpoint") { + estimate = CHECKPOINT_ROW_ESTIMATE_PX; + } else { + estimate = 120; + } + rowEstimateCache.set(row, estimate); + return estimate; +} + +function estimateVirtualItemHeight(item: GatewayTranscriptVirtualItem): number { + if (item.kind === "loadRemoteHistory") return 44; + if (item.kind === "pendingBubble") return 56; + return estimateRowHeight(item.row); +} + +const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(props: { + conversationId?: string; + rows: readonly TranscriptRow[]; + liveStartIndex: number; + activeTurnKey?: string | null; + contentWidth: number; + scrollViewport: HTMLDivElement | null; + isViewportFollowing?: () => boolean; + viewportFollowing: boolean; + navRef?: MutableRefObject; + onAnchorUserRowChange?: (rowKey: string | null) => void; + hasMoreHistory?: boolean; + isLoadingMoreHistory?: boolean; + onLoadEarlierHistory?: () => void; + isStreaming: boolean; + showUsage: boolean; + usageContextWindow?: number; + workspaceRoot?: string; + gitClient?: GitClient | null; + onOpenFileLink?: (link: ChatFileLink) => void; + onLoadUploadedImagePreview?: UploadedImagePreviewLoader; + onResendFromEdit?: ( + messageRef: HistoryMessageRef, + text: string, + uploadedFiles: PendingUploadedFile[], + ) => void; + onBranchConversation?: (messageRef: HistoryMessageRef) => void; + branchPendingMessageId?: string | null; + toolStatus?: string | null; + toolStatusIsCompaction: boolean; + retryAttempts?: readonly RetryAttemptRecord[]; + readOnly?: boolean; + redactToolContent?: boolean; +}) { + const { + conversationId, + rows, + liveStartIndex, + activeTurnKey, + contentWidth, + scrollViewport, + isViewportFollowing, + viewportFollowing, + navRef, + onAnchorUserRowChange, + hasMoreHistory, + isLoadingMoreHistory, + onLoadEarlierHistory, + isStreaming, + showUsage, + usageContextWindow, + workspaceRoot, + gitClient, + onOpenFileLink, + onLoadUploadedImagePreview, + onResendFromEdit, + onBranchConversation, + branchPendingMessageId, + toolStatus, + toolStatusIsCompaction, + retryAttempts, + readOnly = false, + redactToolContent = false, + } = props; + const { locale } = useLocale(); + const [copiedMessageId, setCopiedMessageId] = useState(null); + const [editingMessageId, setEditingMessageId] = useState(null); + const historyIdentityKey = `${conversationId ?? ""}\n${rows[0]?.key ?? ""}`; + const loadCommitDetails = useCommitDetailsLoader(workspaceRoot, gitClient, historyIdentityKey); + + useEffect(() => { + setEditingMessageId(null); + }, [historyIdentityKey]); + + useEffect(() => { + if (!editingMessageId) { + return; + } + const hasEditingRow = rows.some((row) => row.kind === "user" && row.key === editingMessageId); + if (!hasEditingRow) { + setEditingMessageId(null); + } + }, [editingMessageId, rows]); + + const displayedToolStatus = useMemo( + () => normalizeLiveToolStatus(toolStatus ?? null), + [toolStatus], + ); + const displayedToolStatusIsCompaction = toolStatusIsCompaction; + + // The live article: the streaming turn's trailing assistant row while a + // run is active, else the trailing assistant row. It keeps its in-flight + // structural state regardless of `isStreaming` (folding happens at the + // next run_started); the caret tracks `isStreaming` separately so it hides + // cleanly once the stream actually ends. + const liveAssistantIndex = useMemo(() => { + if (activeTurnKey) { + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]; + if (row?.kind === "assistant" && row.turnKey === activeTurnKey) { + return index; + } + } + return -1; + } + return rows.length > 0 && rows[rows.length - 1]?.kind === "assistant" ? rows.length - 1 : -1; + }, [activeTurnKey, rows]); + + // The pending bubble (typing dots / vibing / compacting) shows while busy + // and the transcript has no assistant output for the active exchange yet. + const shouldShowPendingLiveBubble = useMemo(() => { + if (readOnly || !isStreaming) { + return false; + } + if (liveAssistantIndex >= 0) { + return false; + } + if (displayedToolStatusIsCompaction) { + return true; + } + const lastRowKind = rows[rows.length - 1]?.kind; + return !lastRowKind || lastRowKind === "user" || lastRowKind === "checkpoint"; + }, [displayedToolStatusIsCompaction, isStreaming, liveAssistantIndex, readOnly, rows]); + + // Row keys are unique by construction (the row builder's single canonical + // pass) and feed both React reconciliation and the virtualizer's + // measurement cache directly. + const virtualItems = useMemo(() => { + const next: GatewayTranscriptVirtualItem[] = []; + if (!readOnly && hasMoreHistory) { + next.push({ key: "load-remote-history", kind: "loadRemoteHistory" }); + } + for (const row of rows) { + next.push({ key: row.key, kind: "row", row }); + } + if (shouldShowPendingLiveBubble) { + next.push({ key: "live-pending-bubble", kind: "pendingBubble" }); + } + return next; + }, [hasMoreHistory, rows, readOnly, shouldShowPendingLiveBubble]); + + const leadingOffset = !readOnly && hasMoreHistory ? 1 : 0; + // Everything at or after the live boundary (including the pending bubble) + // is force-mounted: a streaming reply must never unmount mid-run. + const forceMountStart = + liveStartIndex >= 0 + ? liveStartIndex + leadingOffset + : shouldShowPendingLiveBubble + ? virtualItems.length - 1 + : -1; + const forceMountStartRef = useRef(forceMountStart); + forceMountStartRef.current = forceMountStart; + + const retryTargetByAssistantKey = useMemo(() => buildRetryTargetMap(rows), [rows]); + const getTranscriptItemKey = useCallback( + // The index branch is unreachable (count === virtualItems.length); it + // only satisfies the type. + (index: number) => virtualItems[index]?.key ?? `virtual-${index}`, + [virtualItems], + ); + + // Restored once per mount: at conversation-switch remounts the viewport is + // already live, so a same-width snapshot skips straight to exact layout. + const [initialMeasurementsCache] = useState( + () => + (conversationId && scrollViewport + ? transcriptMeasurementsLru.restore( + conversationId, + buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), + ) + : null) ?? [], + ); + + const transcriptVirtualizer = useVirtualizer({ + count: virtualItems.length, + getScrollElement: () => scrollViewport, + estimateSize: (index) => { + const item = virtualItems[index]; + return item ? estimateVirtualItemHeight(item) : TRANSCRIPT_ROW_ESTIMATED_HEIGHT; + }, + getItemKey: getTranscriptItemKey, + gap: TRANSCRIPT_ROW_GAP, + overscan: TRANSCRIPT_ROW_OVERSCAN_COUNT, + enabled: scrollViewport !== null, + // End anchoring is enabled only for a detached reader so keyed prepends + // preserve the visible row. While following, start anchoring disables the + // virtualizer's bottom correction and leaves live growth to useScrollFollow. + anchorTo: viewportFollowing ? "start" : "end", + scrollEndThreshold: 8, + initialMeasurementsCache, + rangeExtractor: (range) => extractLiveRange(range, forceMountStartRef.current), + }); + + // TanStack exposes the resize-compensation predicate as an instance field, + // not an option; reassigning per render keeps the closure's inputs current. + // While following it rejects every virtualizer correction; while detached + // it retains estimate/measurement anchoring for rows above the viewport. + transcriptVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = + createLiveRowScrollAdjustPolicy({ + getLiveStartIndex: () => forceMountStartRef.current, + isFollowing: () => isViewportFollowing?.() ?? false, + }); + + // Every mounted row is already tracked by the virtualizer's ResizeObserver, + // which updates its measured height as the centered transcript reflows. + // Do not call measure() on width commits: it clears those fresh measurements + // after the DOM has already resized, so estimate-based row positions can + // overlap without another resize event to repopulate the cache. + + // 楼层跳转:scrollToIndex(align:"start") 后连续几帧重对齐——目标行远处的 + // 估高行在滚动后被真实测量,落点会漂移;对准同一 index 是收敛操作,不会 + // 震荡。收敛期间用户的滚轮/触摸/按键立即取消收敛;新跳转替换旧收敛。 + // 楼层导航当前楼层:以「视口顶缘(+8px 容差)」所落在的用户消息为准——与 + // 跳转的 align:"start" 落位一致,跳转后高亮的必然是刚点的楼层;视口贴近 + // 内容底部时直接取最后一层(否则短对话拼满一屏时底部楼层永远无法成为当前 + // 层)。贴底判定用 scrollHeight(与 scrollTop/clientHeight 同一坐标系, + // 含底部保留区),避免与 getTotalSize 的列表局部坐标错位。 + useTranscriptNavigation({ + items: virtualItems, + getItemKey: (item) => item.key, + getAnchorKey: (itemList, anchorIndex) => { + for (let index = anchorIndex; index >= 0; index -= 1) { + const item = itemList[index]; + if (item?.kind === "row" && item.row.kind === "user") { + return item.row.key; + } + } + return null; + }, + virtualizer: transcriptVirtualizer, + scrollViewport, + navRef, + onAnchorChange: onAnchorUserRowChange, + }); + + // Infinite upward paging: scrolling within one viewport of the top requests + // the previous page through the same handler as the "load earlier history" + // button (which stays as the visible affordance and loading indicator). + // Only scroll events trigger it — opening a conversation lands at the + // bottom and never auto-fetches — and after a page lands the keyed + // anchoring parks the viewport about a page below the top, so walking + // further back keeps paging one request at a time: readers load exactly as + // far as they scroll, servers transfer only the pages actually walked to, + // and a failed fetch retries only on the next user scroll (no hammering). + const autoLoadEarlierInFlightRef = useRef(false); + const maybeAutoLoadEarlierRef = useRef(() => {}); + maybeAutoLoadEarlierRef.current = () => { + if ( + readOnly || + isStreaming || + !hasMoreHistory || + !onLoadEarlierHistory || + isLoadingMoreHistory || + autoLoadEarlierInFlightRef.current || + !scrollViewport || + scrollViewport.scrollTop > scrollViewport.clientHeight + ) { + return; + } + autoLoadEarlierInFlightRef.current = true; + onLoadEarlierHistory(); + }; + useEffect(() => { + if (!scrollViewport || readOnly) return; + const handler = () => maybeAutoLoadEarlierRef.current(); + scrollViewport.addEventListener("scroll", handler, { passive: true }); + return () => scrollViewport.removeEventListener("scroll", handler); + }, [scrollViewport, readOnly]); + useEffect(() => { + // The latch guards the gap between firing and the loading flag landing; + // it releases whenever a load cycle is not (or no longer) running. + if (!isLoadingMoreHistory) { + autoLoadEarlierInFlightRef.current = false; + } + }, [isLoadingMoreHistory]); + + // First paint of a conversation lands at the bottom before the user sees + // anything: scrollToEnd re-targets as dynamic measurements land. The region + // remounts per conversation (keyed by the parent), so this runs once per + // open; read-only shared views keep their own initial position. + const scrollToEndOnceRef = useRef(false); + useLayoutEffect(() => { + if ( + scrollToEndOnceRef.current || + readOnly || + scrollViewport === null || + virtualItems.length === 0 + ) { + return; + } + scrollToEndOnceRef.current = true; + transcriptVirtualizer.scrollToEnd(); + }, [readOnly, scrollViewport, virtualItems.length, transcriptVirtualizer]); + + // Snapshot measured heights for the next open of this conversation. + const saveMeasurementsRef = useRef(() => {}); + saveMeasurementsRef.current = () => { + if (!conversationId || !scrollViewport) return; + transcriptMeasurementsLru.save( + conversationId, + buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), + transcriptVirtualizer.takeSnapshot(), + ); + }; + useEffect(() => () => saveMeasurementsRef.current(), []); + + const virtualRows = transcriptVirtualizer.getVirtualItems(); + + return ( +
+ {virtualRows.map((virtualRow) => { + const virtualItem = virtualItems[virtualRow.index]; + if (!virtualItem) return null; + + if (virtualItem.kind === "loadRemoteHistory") { + return ( +
+ +
+ ); + } + + if (virtualItem.kind === "pendingBubble") { + return ( +
+
+ +
+
+ +
+ {retryAttempts && retryAttempts.length > 0 ? ( + + ) : null} +
+
+
+ ); + } + + const row = virtualItem.row; + if (row.kind === "user") { + return ( +
+ +
+ ); + } + + if (row.kind === "assistant") { + const rowIndex = virtualRow.index - leadingOffset; + const isLatestLiveAssistant = rowIndex === liveAssistantIndex; + const isLatestLiveStreaming = isStreaming && isLatestLiveAssistant; + return ( +
+
+ + {isLatestLiveStreaming ? ( + + ) : null} + {isLatestLiveStreaming && + !shouldShowPendingLiveBubble && + retryAttempts && + retryAttempts.length > 0 ? ( +
+ +
+ ) : null} + {!readOnly && !isLatestLiveStreaming ? ( + + ) : null} +
+
+ ); + } + + if (row.kind === "checkpoint") { + return ( +
+ +
+ ); + } + + return ( +
+
+
Error
+
+
{row.text}
+
+
+
+ ); + })} +
+ ); +}); + +export function GatewayTranscript({ + conversationId, + rows, + liveStartIndex = -1, + activeTurnKey = null, + contentWidth = DEFAULT_CHAT_TRANSCRIPT_WIDTH, + isViewportFollowing, + viewportFollowing = false, + navRef, + onAnchorUserRowChange, + error, + toolStatus, + toolStatusIsCompaction = false, + retryAttempts, + isStreaming = false, + isLoading = false, + loadingTitle, + hasModels = true, + onOpenSettings, + hasMoreHistory = false, + isLoadingMoreHistory = false, + onLoadEarlierHistory, + showUsage = false, + usageContextWindow, + workspaceRoot, + gitClient, + onOpenFileLink, + onLoadUploadedImagePreview, + onResendFromEdit, + onBranchConversation, + branchPendingMessageId, + onSuggestionSelect, + suggestionsDisabled = false, + readOnly = false, + redactToolContent = false, +}: GatewayTranscriptProps) { + const transcriptListRef = useRef(null); + const [transcriptScrollViewport, setTranscriptScrollViewport] = useState( + null, + ); + const rowCount = rows.length; + const inlineErrorText = error?.trim() ?? ""; + const shouldShowInlineError = useMemo(() => { + if (inlineErrorText.length === 0) { + return false; + } + return !rows.some((row) => row.kind === "error" && row.text.trim() === inlineErrorText); + }, [rows, inlineErrorText]); + + useLayoutEffect(() => { + const nextViewport = resolveNearestScrollViewport(transcriptListRef.current); + setTranscriptScrollViewport((current) => (current === nextViewport ? current : nextViewport)); + }); + + if (rowCount === 0 && isLoading) { + return ; + } + + if (rowCount === 0 && !isStreaming) { + const showNoModelsState = !hasModels; + return ( +
+
+ {/* Keyed per conversation so the hero entrance replays when + switching between empty conversations, not just on mount. */} + +
+
+ ); + } + + return ( +
+
+ {/* Keyed remount per conversation: per-conversation state (measured + heights, scroll-to-end latch) initializes fresh, and row keys can + never collide across conversations in the itemSizeCache. */} + + {shouldShowInlineError ? ( +
{inlineErrorText}
+ ) : null} +
+
+
+ ); +} diff --git a/crates/agent-mobile/src/components/RemotePathPickerModal.tsx b/crates/agent-mobile/src/components/RemotePathPickerModal.tsx new file mode 100644 index 000000000..111a8f1d9 --- /dev/null +++ b/crates/agent-mobile/src/components/RemotePathPickerModal.tsx @@ -0,0 +1,758 @@ +import { Dialog } from "@base-ui/react/dialog"; +import { invoke } from "@liveagent/app/shims/tauriCore"; +import { + AlertTriangle, + File, + FolderOpen, + HardDrive, + Home, + Loader2, + Plus, + X, +} from "@liveagent/ui/components/IconSet"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { Input } from "@liveagent/ui/components/ui/input"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + IndividualTreeViewState, + TreeItem, + TreeItemIndex, + TreeViewState, +} from "react-complex-tree"; +import { ControlledTreeEnvironment, Tree } from "react-complex-tree"; +import type { RemoteFsRoot } from "./remotePathPickerPaths"; +import { + basenameFromPath, + findBestRootForPath, + findRouteChild, + joinChildPath, + normalizePathForCompare, + stripTrailingPathSeparators, +} from "./remotePathPickerPaths"; + +// WebUI-only global component: browses paths on the paired desktop (GUI) +// device over the gateway. Directory mode reuses the fs_roots/fs_list_dirs +// commands; file mode lists files alongside directories via fs_list. +// Path helpers live in remotePathPickerPaths.ts: the desktop may be Windows, +// so they handle POSIX and Windows shapes regardless of browser platform. + +export type RemotePathPickerMode = "directory" | "file"; + +type FsRoot = RemoteFsRoot; + +type FsRootsResponse = { + roots: FsRoot[]; +}; + +type FsListDirsResponse = { + path: string; + entries: Array<{ path: string; name: string }>; + truncated: boolean; +}; + +type FsListResponse = { + hasMore: boolean; + entries: Array<{ path: string; kind: string; hidden: boolean }>; +}; + +type ChildEntry = { + path: string; + name: string; + kind: "dir" | "file"; +}; + +type LoadedChildren = { + entries: ChildEntry[]; + truncated: boolean; +}; + +type NodeData = { + path: string; + label: string; + kind: "synthetic-root" | "home" | "root" | "drive" | "dir" | "file"; + loaded: boolean; + truncated?: boolean; +}; + +const TREE_ID = "remote-path-picker"; +const ROOT_ID = "__remote_path_picker_roots__"; +const DEFAULT_MAX_RESULTS = 10000; + +function createNodeItem(path: string, label: string, kind: NodeData["kind"]): TreeItem { + const isFolder = kind !== "file"; + return { + index: path, + isFolder, + children: [], + data: { + path, + label, + kind, + // Files have no children to load. + loaded: kind === "file", + }, + }; +} + +function toErrorMessage(err: unknown, fallback: string) { + if (err instanceof Error && err.message.trim()) return err.message.trim(); + const text = String(err ?? "").trim(); + return text || fallback; +} + +function mergeTreeIndexes(current: TreeItemIndex[] | undefined, additions: TreeItemIndex[]) { + return Array.from(new Set([...(current ?? []), ...additions])); +} + +type RemotePathPickerModalProps = { + mode: RemotePathPickerMode; + initialPath?: string; + title?: string; + description?: string; + onClose: () => void; + onSelect: (path: string) => void; +}; + +export function RemotePathPickerModal(props: RemotePathPickerModalProps) { + const { mode, initialPath = "", title, description, onClose, onSelect } = props; + const { t } = useLocale(); + + const [items, setItems] = useState>>(() => ({ + [ROOT_ID]: { + index: ROOT_ID, + isFolder: true, + children: [], + data: { + path: "", + label: "Roots", + kind: "synthetic-root", + loaded: true, + }, + }, + })); + const [viewState, setViewState] = useState(() => ({ + [TREE_ID]: { + expandedItems: [ROOT_ID], + selectedItems: [], + focusedItem: ROOT_ID, + }, + })); + const [loadingRoots, setLoadingRoots] = useState(true); + const [roots, setRoots] = useState([]); + const [loadingPaths, setLoadingPaths] = useState>(() => new Set()); + const [loadError, setLoadError] = useState(null); + const [newFolderName, setNewFolderName] = useState(""); + const [creatingFolder, setCreatingFolder] = useState(false); + const [createFolderError, setCreateFolderError] = useState(null); + const didExpandInitialPathRef = useRef(false); + // Base UI 控制 open:先置 false 播放退场过渡,onOpenChangeComplete 再通知 + // 调用方卸载(onSelect 已先行 resolve,onClose 的 resolve(null) 为 no-op)。 + const [open, setOpen] = useState(true); + const requestClose = useCallback(() => setOpen(false), []); + + const modalTitle = + title ?? (mode === "file" ? t("settings.filePickerTitle") : t("settings.workdirPickerTitle")); + const modalDescription = + description ?? (mode === "file" ? t("settings.filePickerDesc") : t("settings.workdirDesc")); + + const selectedItem = viewState[TREE_ID]?.selectedItems?.[0] ?? null; + const focusedItem = viewState[TREE_ID]?.focusedItem ?? null; + const selectedPath = + typeof selectedItem === "string" && selectedItem !== ROOT_ID ? selectedItem : ""; + const focusedPath = typeof focusedItem === "string" && focusedItem !== ROOT_ID ? focusedItem : ""; + const activePath = (selectedPath || focusedPath).trim(); + + const headerPath = (selectedPath || initialPath || "").trim(); + + const activeMeta = activePath ? (items[activePath]?.data ?? null) : null; + const activeChildren = activePath ? (items[activePath]?.children ?? null) : null; + const createFolderName = newFolderName.trim(); + const activeIsDirectory = Boolean(activeMeta && activeMeta.kind !== "file"); + const canCreateFolder = Boolean( + activePath && activeIsDirectory && createFolderName && !creatingFolder, + ); + + const selectedMeta = selectedPath ? (items[selectedPath]?.data ?? null) : null; + const canConfirm = + mode === "file" + ? selectedMeta?.kind === "file" + : Boolean(selectedPath && selectedMeta?.kind !== "file"); + + const statusLine = useMemo(() => { + if (loadError) { + return { + kind: "error" as const, + text: `${t("settings.dirLoadFailed")}${loadError}`, + }; + } + if (loadingRoots || loadingPaths.size > 0) { + return { + kind: "loading" as const, + text: t("settings.loadingDirs"), + }; + } + if ( + activeIsDirectory && + activeMeta?.loaded && + Array.isArray(activeChildren) && + activeChildren.length === 0 + ) { + return { + kind: "empty" as const, + text: mode === "file" ? t("settings.emptyDir") : t("settings.noSubdirs"), + }; + } + if (activeMeta?.truncated) { + return { + kind: "warn" as const, + text: t("settings.tooManyDirs"), + }; + } + return null; + }, [ + activeChildren, + activeIsDirectory, + activeMeta?.loaded, + activeMeta?.truncated, + loadError, + loadingPaths.size, + loadingRoots, + mode, + t, + ]); + + function updateTreeViewState( + treeId: string, + updater: (prev: IndividualTreeViewState) => IndividualTreeViewState, + ) { + setViewState((prev) => { + const treePrev = prev[treeId] ?? {}; + return { + ...prev, + [treeId]: updater(treePrev), + }; + }); + } + + useEffect(() => { + let cancelled = false; + + async function loadRoots() { + setLoadingRoots(true); + setLoadError(null); + try { + const resp = await invoke("fs_roots"); + const roots = Array.isArray(resp.roots) ? resp.roots : []; + const rootChildren = roots.map((root) => root.path).filter(Boolean); + + setRoots(roots); + setItems((prev) => { + const next: Record> = { ...prev }; + next[ROOT_ID] = { + ...next[ROOT_ID], + isFolder: true, + children: rootChildren, + }; + + for (const root of roots) { + const kind: NodeData["kind"] = + root.kind === "home" ? "home" : root.kind === "drive" ? "drive" : "root"; + next[root.path] = createNodeItem(root.path, root.label || root.path, kind); + } + return next; + }); + + updateTreeViewState(TREE_ID, (treePrev) => ({ + ...treePrev, + expandedItems: treePrev.expandedItems?.includes(ROOT_ID) + ? treePrev.expandedItems + : [ROOT_ID], + selectedItems: treePrev.selectedItems ?? [], + focusedItem: treePrev.focusedItem ?? ROOT_ID, + })); + } catch (err) { + if (cancelled) return; + setLoadError(toErrorMessage(err, "Failed to load roots")); + } finally { + if (!cancelled) setLoadingRoots(false); + } + } + + void loadRoots(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (loadingRoots || didExpandInitialPathRef.current) return; + + const targetPath = stripTrailingPathSeparators(initialPath); + if (!targetPath || roots.length === 0) return; + + const root = findBestRootForPath(targetPath, roots); + if (!root) return; + + didExpandInitialPathRef.current = true; + let cancelled = false; + + async function expandInitialPath() { + const route: string[] = [root.path]; + let currentPath = root.path; + let reachedTarget = + normalizePathForCompare(currentPath) === normalizePathForCompare(targetPath); + let targetIsFile = false; + + try { + for (let depth = 0; depth < 128 && !reachedTarget; depth += 1) { + const resp = await loadDirectoryChildren(currentPath); + if (cancelled) return; + + const child = findRouteChild(targetPath, resp.entries); + if (!child?.path) break; + + currentPath = child.path; + targetIsFile = child.kind === "file"; + if (!targetIsFile) { + route.push(currentPath); + } + reachedTarget = + normalizePathForCompare(currentPath) === normalizePathForCompare(targetPath); + if (targetIsFile) break; + } + + if (reachedTarget && !targetIsFile) { + try { + await loadDirectoryChildren(currentPath); + } catch { + // Keep the selected directory visible even if its children cannot be read. + } + } + } catch { + // The shared error banner is updated by loadDirectoryChildren. + } + + if (cancelled) return; + + const selectedPathForState = reachedTarget + ? currentPath + : (route[route.length - 1] ?? root.path); + const expandedRoute = [ROOT_ID, ...route]; + + updateTreeViewState(TREE_ID, (treePrev) => ({ + ...treePrev, + expandedItems: mergeTreeIndexes(treePrev.expandedItems, expandedRoute), + selectedItems: [selectedPathForState], + focusedItem: selectedPathForState, + })); + } + + void expandInitialPath(); + return () => { + cancelled = true; + }; + }, [initialPath, loadingRoots, roots]); + + async function fetchChildren(path: string): Promise { + if (mode === "directory") { + const resp = await invoke("fs_list_dirs", { + path, + max_results: DEFAULT_MAX_RESULTS, + } as never); + const entries = (Array.isArray(resp.entries) ? resp.entries : []) + .filter((entry) => entry.path) + .map((entry) => ({ + path: entry.path, + name: entry.name || basenameFromPath(entry.path), + kind: "dir" as const, + })); + return { entries, truncated: Boolean(resp.truncated) }; + } + + // File mode: list immediate children (directories + files) of `path`. + const resp = await invoke("fs_list", { + workdir: path, + depth: 1, + max_results: DEFAULT_MAX_RESULTS, + show_hidden: false, + } as never); + const entries = (Array.isArray(resp.entries) ? resp.entries : []) + .filter((entry) => entry.path && (entry.kind === "dir" || entry.kind === "file")) + .map((entry) => ({ + path: joinChildPath(path, entry.path), + name: basenameFromPath(entry.path) || entry.path, + kind: entry.kind as "dir" | "file", + })) + .sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1; + return left.name.localeCompare(right.name); + }); + return { entries, truncated: Boolean(resp.hasMore) }; + } + + async function loadDirectoryChildren(path: string): Promise { + setLoadingPaths((prev) => { + const next = new Set(prev); + next.add(path); + return next; + }); + setLoadError(null); + + try { + const loaded = await fetchChildren(path); + const childPaths = loaded.entries.map((entry) => entry.path); + + setItems((prev) => { + const next: Record> = { ...prev }; + const parent = next[path]; + if (!parent) return prev; + + next[path] = { + ...parent, + isFolder: true, + children: childPaths, + data: { + ...parent.data, + loaded: true, + truncated: loaded.truncated, + }, + }; + + for (const entry of loaded.entries) { + if (next[entry.path]) continue; + next[entry.path] = createNodeItem(entry.path, entry.name, entry.kind); + } + + return next; + }); + + return loaded; + } catch (err) { + setLoadError(toErrorMessage(err, "Failed to list directories")); + throw err; + } finally { + setLoadingPaths((prev) => { + const next = new Set(prev); + next.delete(path); + return next; + }); + } + } + + async function loadChildren(path: string) { + if (!path.trim()) return; + const current = items[path]; + if (!current || !current.isFolder) return; + if (current.data?.loaded) return; + if (loadingPaths.has(path)) return; + + try { + await loadDirectoryChildren(path); + } catch { + // The shared error banner is updated by loadDirectoryChildren. + } + } + + async function createFolderInActivePath() { + const parent = activePath.trim(); + const name = createFolderName; + if (!parent || !name || creatingFolder) return; + + setCreatingFolder(true); + setCreateFolderError(null); + try { + const resp = await invoke<{ path: string }>("system_create_project_folder", { + parent, + name, + }); + const createdPath = stripTrailingPathSeparators(resp.path); + if (!createdPath) { + throw new Error("Created folder path is empty"); + } + + try { + await loadDirectoryChildren(parent); + } catch { + // Keep the newly created folder selectable even if refreshing the parent fails. + } + + const createdLabel = name || basenameFromPath(createdPath) || createdPath; + setItems((prev) => { + const next: Record> = { ...prev }; + const parentItem = next[parent]; + if (parentItem) { + const children = Array.isArray(parentItem.children) ? parentItem.children : []; + next[parent] = { + ...parentItem, + isFolder: true, + children: mergeTreeIndexes(children, [createdPath]), + data: { + ...parentItem.data, + loaded: true, + }, + }; + } + next[createdPath] = next[createdPath] ?? createNodeItem(createdPath, createdLabel, "dir"); + return next; + }); + + updateTreeViewState(TREE_ID, (treePrev) => ({ + ...treePrev, + expandedItems: mergeTreeIndexes(treePrev.expandedItems, [ROOT_ID, parent]), + selectedItems: [createdPath], + focusedItem: createdPath, + })); + setNewFolderName(""); + setLoadError(null); + } catch (err) { + setCreateFolderError(toErrorMessage(err, t("settings.createFolderFailed"))); + } finally { + setCreatingFolder(false); + } + } + + // 挂 z-[120]:需要压过新建 worktree 弹窗(z-[110])等上层来源;作为嵌套 + // Base UI Dialog 打开时 DOM 顺序也保证在后。settings-modal-* 类只复用其 + // 响应式布局规则,动画由 modal-dialog-* 的 Base UI 过渡属性驱动。 + return ( + { + if (!nextOpen) onClose(); + }} + > + + + + +
+
+ {mode === "file" ? ( + + ) : ( + + )} +
+
+ {modalTitle} + + {modalDescription} + +
+ +
+ +
+
+
+ {mode === "file" ? t("settings.pathPickerPathLabel") : t("settings.workdir")} +
+ +
+
+ +
+
+
+ + ~ +
+ · +
+ + Root +
+
+ + {mode === "directory" ? ( +
+
+ { + setNewFolderName(event.currentTarget.value); + if (createFolderError) { + setCreateFolderError(null); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void createFolderInActivePath(); + }} + placeholder={ + activePath + ? t("settings.newFolderNamePlaceholder") + : t("settings.newFolderSelectParentFirst") + } + disabled={!activePath || creatingFolder} + className="h-9" + /> + +
+ {createFolderError ? ( +
+ + {createFolderError} +
+ ) : null} +
+ ) : null} + +
+ item.data.label} + viewState={viewState} + onExpandItem={(item, treeId) => { + const index = typeof item.index === "string" ? item.index : ""; + if (!index || index === ROOT_ID) return; + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + expandedItems: treePrev.expandedItems?.includes(index) + ? treePrev.expandedItems + : [...(treePrev.expandedItems ?? []), index], + })); + void loadChildren(index); + }} + onCollapseItem={(item, treeId) => { + const index = typeof item.index === "string" ? item.index : ""; + if (!index || index === ROOT_ID) return; + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + expandedItems: (treePrev.expandedItems ?? []).filter( + (entry) => entry !== index, + ), + })); + }} + onSelectItems={(selectedItems, treeId) => { + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + selectedItems, + })); + }} + onFocusItem={(item, treeId) => { + updateTreeViewState(treeId, (treePrev) => ({ + ...treePrev, + focusedItem: item.index, + })); + }} + > + + +
+ + {statusLine ? ( +
+ {statusLine.kind === "loading" ? ( + + ) : statusLine.kind === "error" ? ( + + ) : ( + + )} + {statusLine.text} +
+ ) : null} +
+ +
+ + +
+
+
+
+
+ ); +} + +type PickPathOptions = { + mode: RemotePathPickerMode; + initialPath?: string; + title?: string; + description?: string; +}; + +type PendingPick = PickPathOptions & { + resolve: (path: string | null) => void; +}; + +/** + * Promise-based entry point: `pickPath()` resolves with the chosen remote + * path, or null when the picker is dismissed. Render `pathPickerElement` + * once near the caller's root. + */ +export function useRemotePathPicker() { + const [pending, setPending] = useState(null); + + const pickPath = useCallback( + (options: PickPathOptions) => + new Promise((resolve) => { + setPending({ ...options, resolve }); + }), + [], + ); + + const pathPickerElement = pending ? ( + { + pending.resolve(path); + }} + onClose={() => { + // No-op when a selection already resolved the promise. + pending.resolve(null); + setPending(null); + }} + /> + ) : null; + + return { pickPath, pathPickerElement }; +} diff --git a/crates/agent-mobile/src/components/remotePathPickerPaths.ts b/crates/agent-mobile/src/components/remotePathPickerPaths.ts new file mode 100644 index 000000000..c4c612e27 --- /dev/null +++ b/crates/agent-mobile/src/components/remotePathPickerPaths.ts @@ -0,0 +1,84 @@ +// Pure path helpers for RemotePathPickerModal. The picker browses the paired +// desktop's file system, so every helper must accept both POSIX ("/a/b") and +// Windows ("C:\\a", "C:/a", "\\\\server\\share") shapes regardless of the +// browser's own platform. + +export type RemoteFsRoot = { + id: string; + path: string; + kind: "home" | "root" | "drive"; + label: string; +}; + +export type RemoteChildEntry = { + path: string; + name: string; + kind: "dir" | "file"; +}; + +export function stripTrailingPathSeparators(path: string) { + const trimmed = path.trim(); + if (!trimmed) return ""; + if (trimmed === "/" || /^[A-Za-z]:[\\/]?$/.test(trimmed)) return trimmed; + return trimmed.replace(/[\\/]+$/, ""); +} + +export function normalizePathForCompare(path: string) { + const normalized = stripTrailingPathSeparators(path).replace(/\\/g, "/"); + return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +export function isSameOrDescendantPath(path: string, ancestor: string) { + const current = normalizePathForCompare(path); + const parent = normalizePathForCompare(ancestor); + if (!current || !parent) return false; + if (current === parent) return true; + if (parent === "/") return current.startsWith("/"); + if (/^[a-z]:\/?$/.test(parent)) { + return current.startsWith(parent.endsWith("/") ? parent : `${parent}/`); + } + return current.startsWith(`${parent}/`); +} + +export function findBestRootForPath(path: string, roots: RemoteFsRoot[]) { + return ( + roots + .filter((root) => root.path && isSameOrDescendantPath(path, root.path)) + .sort( + (left, right) => + normalizePathForCompare(right.path).length - normalizePathForCompare(left.path).length, + )[0] ?? null + ); +} + +export function findRouteChild(targetPath: string, entries: RemoteChildEntry[]) { + return ( + entries + .filter((entry) => entry.path && isSameOrDescendantPath(targetPath, entry.path)) + .sort( + (left, right) => + normalizePathForCompare(right.path).length - normalizePathForCompare(left.path).length, + )[0] ?? null + ); +} + +export function basenameFromPath(path: string) { + const normalized = stripTrailingPathSeparators(path); + if (!normalized) return ""; + const parts = normalized.split(/[\\/]+/).filter(Boolean); + return parts[parts.length - 1] ?? normalized; +} + +export function joinChildPath(parent: string, relative: string) { + const base = stripTrailingPathSeparators(parent); + const child = relative.replace(/^[\\/]+/, ""); + if (!base) return child; + if (base === "/") return `/${child}`; + // Drive roots survive stripTrailingPathSeparators with their separator + // attached ("C:\\" / "C:/") or bare ("C:"); join without doubling it. + const driveRoot = /^([A-Za-z]:)[\\/]?$/.exec(base); + if (driveRoot) { + return `${driveRoot[1]}${base.includes("\\") ? "\\" : "/"}${child}`; + } + return `${base}${base.includes("\\") ? "\\" : "/"}${child}`; +} diff --git a/crates/agent-mobile/src/i18n/config.ts b/crates/agent-mobile/src/i18n/config.ts new file mode 100644 index 000000000..c2668e5cb --- /dev/null +++ b/crates/agent-mobile/src/i18n/config.ts @@ -0,0 +1,209 @@ +import { createHostTranslations, type Locale } from "@liveagent/ui/i18n/hostTranslations"; + +export type { Locale } from "@liveagent/ui/i18n/hostTranslations"; +export { + DEFAULT_LOCALE, + normalizeLocale, + SUPPORTED_LOCALES, +} from "@liveagent/ui/i18n/hostTranslations"; + +/** + * Host translation overrides. Shared, host-neutral messages live in agent-ui. + */ +export const WEBUI_TRANSLATION_OVERRIDES: Record> = { + "zh-CN": { + "chat.runtime.settingsSyncTitle": "正在同步桌面端设置", + "chat.runtime.protocolIncompatible": + "桌面端版本不支持可靠聊天同步。请更新 LiveAgent 桌面端后再使用 WebUI 聊天。", + "chat.runtime.protocolIncompatiblePlaceholder": "请先更新 LiveAgent 桌面端", + "chat.queue.moveDown": "下移", + "chat.upload.maxFilesIgnored": "最多上传 {max} 个文件,已忽略 {count} 个额外文件", + "projectTools.sshLocalForwardHelp": + "仅监听桌面端本机 127.0.0.1;本地端口留空或填 0 自动分配,远端主机留空默认 SSH 服务器自身的 127.0.0.1", + "projectTools.sshLocalForwardListening": "正在监听(仅桌面端本机)", + "settings.navAgentManagement": "多客户端管理", + "settings.devicesTitle": "多客户端管理", + "settings.devicesDesc": "管理连接到此网关的 Agent 及其独立凭证", + "settings.devicesRefresh": "刷新", + "settings.devicesFilterAll": "全部", + "settings.devicesFilterOnline": "在线", + "settings.devicesFilterOffline": "离线", + "settings.devicesFilterEmpty": "没有符合条件的客户端", + "settings.devicesFilterEmptyDesc": "请选择其他状态或刷新后重试。", + "settings.devicesLoginTitle": "验证网关 Token", + "settings.devicesLoginDesc": "当前登录信息不可用,请重新输入网关 Token。", + "settings.devicesGatewayToken": "网关 Token", + "settings.devicesLogin": "验证", + "settings.devicesAdd": "新增客户端", + "settings.devicesAddTitle": "新增客户端", + "settings.devicesAddDesc": "填写客户端自动生成的 Agent ID,并签发独立访问凭证。", + "settings.devicesName": "客户端名称", + "settings.devicesNamePlaceholder": "可选,例如办公室电脑", + "settings.devicesAgentId": "Agent ID", + "settings.devicesAgentIdPlaceholder": "粘贴客户端自动生成的 agent-UUID", + "settings.devicesIssueShort": "签发", + "settings.devicesRotate": "轮换", + "settings.devicesRotateTitle": "轮换客户端凭证?", + "settings.devicesRotateDesc": + "轮换会立即断开当前客户端,旧凭证无法重连,新凭证明文仅显示一次。", + "settings.devicesEditName": "编辑", + "settings.devicesIssuedTitle": "客户端凭证已签发", + "settings.devicesTokenOnce": + "该令牌可持续用于客户端连接,但明文仅显示一次,请立即复制并妥善保存。", + "settings.devicesEmpty": "暂无已登记客户端", + "settings.devicesEmptyDesc": "连接客户端,或点击新增客户端签发独立凭证。", + "settings.devicesLoading": "正在加载客户端…", + "settings.devicesOnlineStatus": "在线", + "settings.devicesOfflineStatus": "离线", + "settings.devicesUnknownStatus": "状态确认中", + "settings.devicesIndependentTokenIssued": "已签发独立凭证", + "settings.devicesIndependentTokenMissing": "未签发独立凭证", + "settings.devicesDelete": "删除", + "settings.devicesDeleteTitle": "删除客户端?", + "settings.devicesDeleteDesc": + "将删除整条记录、使独立凭证失效并断开连接;使用共享网关 Token 的客户端可能自动重新登记:", + "settings.devicesPage": "页码", + "settings.devicesPrevious": "上一页", + "settings.devicesNext": "下一页", + "settings.devicesLoadFailed": "加载客户端列表失败。", + "settings.devicesIssueFailed": "签发客户端凭证失败。", + "settings.devicesNameUpdateFailed": "修改客户端名称失败。", + "settings.devicesDeleteFailed": "删除客户端失败。", + "settings.devicesTokenRequired": "请输入网关 Token。", + "settings.devicesAgentRequired": "请输入 Agent ID。", + "settings.devicesAgentInvalid": "Agent ID 必须是客户端自动生成的 agent-UUIDv4。", + "settings.memoryOrganizerBackToList": "返回历史列表", + "settings.systemProxyDesc": + "生效范围:桌面端本地命令环境变量(Bash / 后台任务 / 自动化脚本)、勾选“使用应用代理”的供应商请求、Hook / Cron HTTP 任务、聊天图片加载、更新检查与技能下载。", + "settings.workdirDesc": "选择这个项目的文件夹。文件工具会在所选项目目录内读写/搜索。", + "settings.workdirPickerTitle": "选择项目文件夹", + "settings.filePickerTitle": "选择文件", + "settings.filePickerDesc": "从桌面设备中选择一个文件", + "settings.pathPickerPathLabel": "路径", + "settings.emptyDir": "此目录为空", + "settings.select": "选择", + "settings.loadingDirs": "正在加载目录…", + "settings.dirLoadFailed": "读取目录失败:", + "settings.noSubdirs": "此目录下没有子目录", + "settings.tooManyDirs": "目录过多,已截断显示", + "settings.createFolder": "新建文件夹", + "settings.newFolderNamePlaceholder": "新文件夹名称", + "settings.newFolderSelectParentFirst": "先选择父目录", + "settings.createFolderFailed": "新建文件夹失败", + "settings.remoteAgentIdPlaceholder": "例如:macbook-pro", + "settings.remoteAgentIdHint": "本地 Agent 的唯一标识,在 WebUI 中显示,留空则自动使用主机名", + "settings.remoteWebTerminalHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可启动并控制本机项目终端。", + "settings.remoteWebSshTerminalHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可使用本机保存的 SSH 配置创建和控制远程交互式终端。", + "settings.remoteWebGitHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", + "settings.remoteWebTunnelsHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可为 localhost 或 IP 地址 HTTP 服务创建和关闭临时访问链接。", + "settings.remoteHeartbeatHint": + "与 Gateway 连接的保活心跳间隔(生效范围 10-60 秒),用于维持连接和检测在线状态", + "settings.remoteInfoBanner": + "启用后,本地 LiveAgent 将通过 WebSocket(v2 协议)连接云端 Gateway。你可以在浏览器中通过 WebUI 远程发送 Chat 消息、管理 Cron 任务和查看历史记录。所有工具执行仍在本地完成,远程端仅转发指令和结果。", + }, + "en-US": { + "chat.runtime.settingsSyncTitle": "Syncing desktop settings", + "chat.runtime.protocolIncompatible": + "This desktop version does not support reliable chat sync. Update LiveAgent desktop before using WebUI chat.", + "chat.runtime.protocolIncompatiblePlaceholder": "Update LiveAgent desktop to continue", + "chat.queue.moveDown": "Move down", + "chat.upload.maxFilesIgnored": "Upload up to {max} files; {count} extra files were ignored", + "projectTools.sshLocalForwardHelp": + "Listens on the desktop machine's 127.0.0.1 only. Leave the local port empty or 0 for auto-assign; leave the remote host empty for 127.0.0.1 on the SSH server.", + "projectTools.sshLocalForwardListening": "Listening on the desktop machine only", + "settings.navAgentManagement": "Multi-client Management", + "settings.devicesTitle": "Multi-client Management", + "settings.devicesDesc": "Manage Agents connected to this Gateway and their credentials", + "settings.devicesRefresh": "Refresh", + "settings.devicesFilterAll": "All", + "settings.devicesFilterOnline": "Online", + "settings.devicesFilterOffline": "Offline", + "settings.devicesFilterEmpty": "No matching clients", + "settings.devicesFilterEmptyDesc": "Choose another status or refresh and try again.", + "settings.devicesLoginTitle": "Verify Gateway Token", + "settings.devicesLoginDesc": + "The current login is unavailable. Enter the Gateway Token again to continue.", + "settings.devicesGatewayToken": "Gateway Token", + "settings.devicesLogin": "Verify", + "settings.devicesAdd": "Add client", + "settings.devicesAddTitle": "Add client", + "settings.devicesAddDesc": + "Enter the Agent ID generated by the client and issue an independent credential.", + "settings.devicesName": "Client name", + "settings.devicesNamePlaceholder": "Optional, for example Office desktop", + "settings.devicesAgentId": "Agent ID", + "settings.devicesAgentIdPlaceholder": "Paste the agent-UUID generated by the client", + "settings.devicesIssueShort": "Issue", + "settings.devicesRotate": "Rotate", + "settings.devicesRotateTitle": "Rotate client credential?", + "settings.devicesRotateDesc": + "Rotation immediately disconnects the client. The old credential cannot reconnect, and the new plaintext is shown once.", + "settings.devicesEditName": "Edit", + "settings.devicesIssuedTitle": "Client credential issued", + "settings.devicesTokenOnce": + "This token can be reused for client connections, but its plaintext is shown only once. Copy and store it now.", + "settings.devicesEmpty": "No registered clients", + "settings.devicesEmptyDesc": + "Connect a client or select Add client to issue an independent credential.", + "settings.devicesLoading": "Loading clients…", + "settings.devicesOnlineStatus": "Online", + "settings.devicesOfflineStatus": "Offline", + "settings.devicesUnknownStatus": "Checking status", + "settings.devicesIndependentTokenIssued": "Independent credential issued", + "settings.devicesIndependentTokenMissing": "No independent credential", + "settings.devicesDelete": "Delete", + "settings.devicesDeleteTitle": "Delete client?", + "settings.devicesDeleteDesc": + "This deletes the record, invalidates its credential, and closes its connection. A client using the shared Gateway Token may register again automatically:", + "settings.devicesPage": "Page", + "settings.devicesPrevious": "Previous", + "settings.devicesNext": "Next", + "settings.devicesLoadFailed": "Failed to load clients.", + "settings.devicesIssueFailed": "Failed to issue client credential.", + "settings.devicesNameUpdateFailed": "Failed to update the client name.", + "settings.devicesDeleteFailed": "Failed to delete the client.", + "settings.devicesTokenRequired": "Enter the Gateway Token.", + "settings.devicesAgentRequired": "Enter an Agent ID.", + "settings.devicesAgentInvalid": "Agent ID must be an agent-UUIDv4 generated by the client.", + "settings.memoryOrganizerBackToList": "Back to history list", + "settings.systemProxyDesc": + "Applies to: local command environment on the desktop (Bash / background tasks / automation scripts), providers with “Use app proxy” checked, Hook / Cron HTTP tasks, chat image loading, update checks and skill downloads.", + "settings.workdirDesc": + "Choose this project's folder. File tools read/write/search within the selected project directory.", + "settings.workdirPickerTitle": "Select Project Folder", + "settings.filePickerTitle": "Select File", + "settings.filePickerDesc": "Pick a file from the desktop device", + "settings.pathPickerPathLabel": "Path", + "settings.emptyDir": "This directory is empty", + "settings.select": "Select", + "settings.loadingDirs": "Loading directories…", + "settings.dirLoadFailed": "Failed to read directory: ", + "settings.noSubdirs": "No subdirectories", + "settings.tooManyDirs": "Too many directories, results truncated", + "settings.createFolder": "New Folder", + "settings.newFolderNamePlaceholder": "New folder name", + "settings.newFolderSelectParentFirst": "Select a parent folder first", + "settings.createFolderFailed": "Failed to create folder", + "settings.remoteAgentIdPlaceholder": "e.g. macbook-pro", + "settings.remoteAgentIdHint": + "Unique identifier for this Agent shown in WebUI. Leave empty to use hostname", + "settings.remoteWebTerminalHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can start and control local project terminals.", + "settings.remoteWebSshTerminalHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can create and control remote interactive terminals using SSH profiles saved on the desktop.", + "settings.remoteWebGitHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can run branch, stage, commit, and sync operations on local projects.", + "settings.remoteWebTunnelsHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can create and close temporary links for localhost or IP-address HTTP services.", + "settings.remoteHeartbeatHint": + "Keepalive heartbeat interval for the Gateway connection (effective range 10-60 seconds), used to maintain the connection and detect online status", + "settings.remoteInfoBanner": + "When enabled, the local LiveAgent connects to the cloud Gateway over WebSocket (protocol v2). You can remotely send Chat messages, manage Cron tasks, and view history through the WebUI in your browser. All tool execution remains local — the remote end only relays commands and results.", + }, +}; + +export const { translations, t } = createHostTranslations(WEBUI_TRANSLATION_OVERRIDES); diff --git a/crates/agent-mobile/src/index.css b/crates/agent-mobile/src/index.css new file mode 100644 index 000000000..f1876feaa --- /dev/null +++ b/crates/agent-mobile/src/index.css @@ -0,0 +1,111 @@ +@import "tailwindcss"; +@import "../../agent-ui/src/styles/base.css"; +@import "../../agent-ui/src/styles/transcript.css"; +@import "../../agent-ui/src/styles/common.css"; +@config "../tailwind.config.js"; +@source "../../agent-ui/src"; + +@font-face { + font-family: "OpenAI Sans Semibold"; + src: url("../../agent-ui/src/assets/font/OpenAISans-Semibold.woff2") format("woff2"); + font-style: normal; + font-weight: normal; + font-display: swap; +} + +@layer components { + .sidebar-context-menu[data-state="open"] { + animation: sidebarMenuIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); + --tw-enter-opacity: initial; + --tw-enter-scale: initial; + --tw-enter-translate-x: initial; + } + + .sidebar-context-menu[data-state="closed"] { + animation: sidebarMenuOut 0.12s cubic-bezier(0.4, 0, 1, 1); + --tw-exit-opacity: initial; + --tw-exit-scale: initial; + --tw-exit-translate-x: initial; + } + + @keyframes sidebarMenuIn { + from { + opacity: 0; + transform: translate(var(--sidebar-menu-slide-x), var(--sidebar-menu-slide-y)) scale(0.96); + } + to { + opacity: 1; + transform: translate(0, 0) scale(1); + } + } + + @keyframes sidebarMenuOut { + from { + opacity: 1; + transform: translate(0, 0) scale(1); + } + to { + opacity: 0; + transform: translate( + calc(var(--sidebar-menu-slide-x) / 2), + calc(var(--sidebar-menu-slide-y) / 2) + ) + scale(0.98); + } + } + + .mention-composer.is-empty::before { + left: 0; + right: 0; + padding-right: inherit; + } + + @keyframes composerReasoningDropdownIn { + from { + opacity: 0; + transform: translateY(6px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes composerReasoningDropdownOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(5px) scale(0.975); + } + } + + .composer-reasoning-trigger[data-state="open"] { + box-shadow: + 0 8px 18px -14px rgba(88, 28, 135, 0.55), + inset 0 0 0 1px rgba(167, 139, 250, 0.12); + } + + .composer-branch-dropdown[data-state="open"] { + animation: composerReasoningDropdownIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: var(--transform-origin, bottom left); + } + + .composer-branch-dropdown[data-state="closed"] { + animation: composerReasoningDropdownOut 0.12s cubic-bezier(0.4, 0, 1, 1) forwards; + transform-origin: var(--transform-origin, bottom left); + } + + @media (prefers-reduced-motion: reduce) { + .composer-branch-dropdown { + animation: none !important; + } + + .composer-reasoning-trigger, + .composer-reasoning-trigger > svg:last-child { + transition: none !important; + } + } +} diff --git a/crates/agent-mobile/src/lib/adminApi.ts b/crates/agent-mobile/src/lib/adminApi.ts new file mode 100644 index 000000000..3d9cb236a --- /dev/null +++ b/crates/agent-mobile/src/lib/adminApi.ts @@ -0,0 +1,125 @@ +// 网关管理平面 REST 客户端:Agent 目录、名称与独立凭证管理。 +// 与聊天/控制平面(gatewaySocket)分离——管理操作是 REST + 网关 Token(Bearer), +// 参照 uploadReadableFiles.ts / gatewayAuth.ts 的既有范式。 +import { normalizeGatewayAccessToken } from "@/lib/gatewayAuth"; +import { getGatewayOrigin } from "@/lib/gatewayOrigin"; + +// AdminAgentEntry 是目录条目(对应 Go agentDirectoryEntry 的 JSON 形状)。 +export type AdminAgentEntry = { + agent_id: string; + online: boolean; + has_token: boolean; + registered_at: string; + token_created_at?: string; + name: string; + agent_version?: string; + connected_since?: number; +}; + +// AdminAgentsPage 是数据库分页的一页 Agent 目录及实时状态。 +export type AdminAgentsPage = { + agents: AdminAgentEntry[]; + page: number; + page_size: number; + total: number; + has_more: boolean; +}; + +export type AdminAgentStatus = "all" | "online" | "offline"; + +const GENERATED_AGENT_ID_PATTERN = + /^agent-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +export function isGeneratedAgentID(value: string): boolean { + return GENERATED_AGENT_ID_PATTERN.test(value.trim()); +} + +async function readError(response: Response, fallback: string): Promise { + const raw = (await response.text()).trim(); + if (!raw) { + return fallback; + } + try { + const payload = JSON.parse(raw) as { error?: unknown; message?: unknown }; + const text = + typeof payload.error === "string" + ? payload.error + : typeof payload.message === "string" + ? payload.message + : ""; + return text.trim() || raw; + } catch { + return raw; + } +} + +function authHeaders(token: string): HeadersInit { + const normalized = normalizeGatewayAccessToken(token); + if (!normalized) { + throw new Error("请输入管理 Token。"); + } + return { Authorization: `Bearer ${normalized}` }; +} + +export async function listAdminAgents( + token: string, + page: number, + pageSize: number, + status: AdminAgentStatus = "all", +): Promise { + const url = new URL(`${getGatewayOrigin()}/api/agents`); + url.searchParams.set("page", String(page)); + url.searchParams.set("page_size", String(pageSize)); + url.searchParams.set("status", status); + const response = await fetch(url, { headers: authHeaders(token) }); + if (!response.ok) { + throw new Error(await readError(response, "加载 Agent 目录失败。")); + } + return (await response.json()) as AdminAgentsPage; +} + +// issueAdminToken 签发/轮换凭证并让当前 Agent 会话立即下线;明文只在本次响应返回,调用方须立即展示且不可再取。 +export async function issueAdminToken( + token: string, + agentId: string, + name: string, +): Promise { + const url = `${getGatewayOrigin()}/api/agents/${encodeURIComponent(agentId)}/token`; + const response = await fetch(url, { + method: "POST", + headers: { ...authHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + if (!response.ok) { + throw new Error(await readError(response, "签发凭证失败。")); + } + const payload = (await response.json()) as { token?: string }; + if (!payload.token) { + throw new Error("签发响应缺少凭证明文。"); + } + return payload.token; +} + +export async function updateAdminAgentName( + token: string, + agentId: string, + name: string, +): Promise { + const url = `${getGatewayOrigin()}/api/agents/${encodeURIComponent(agentId)}`; + const response = await fetch(url, { + method: "PATCH", + headers: { ...authHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + if (!response.ok) { + throw new Error(await readError(response, "修改客户端名称失败。")); + } +} + +export async function deleteAdminAgent(token: string, agentId: string): Promise { + const url = `${getGatewayOrigin()}/api/agents/${encodeURIComponent(agentId)}`; + const response = await fetch(url, { method: "DELETE", headers: authHeaders(token) }); + if (!response.ok) { + throw new Error(await readError(response, "删除客户端失败。")); + } +} diff --git a/crates/agent-mobile/src/lib/agentTypes.ts b/crates/agent-mobile/src/lib/agentTypes.ts new file mode 100644 index 000000000..6bd996dae --- /dev/null +++ b/crates/agent-mobile/src/lib/agentTypes.ts @@ -0,0 +1,98 @@ +import type { TSchema } from "@sinclair/typebox"; + +export type TextContent = { + type: "text"; + text: string; + textSignature?: string; +}; + +export type ThinkingContent = { + type: "thinking"; + thinking: string; + thinkingSignature?: string; + redacted?: boolean; +}; + +export type ImageContent = { + type: "image"; + data: string; + mimeType: string; +}; + +export type ToolCall = { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + thoughtSignature?: string; +}; + +export type HostedSearchContent = { + type: "hostedSearch"; + id: string; + provider?: string; + status?: "searching" | "completed" | "failed"; + queries?: string[]; + sources?: Array<{ + url: string; + title?: string; + snippet?: string; + citedText?: string; + sourceType?: "source" | "citation"; + }>; + updatedAt?: number; +}; + +export type Usage = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; +}; + +export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; + +export type UserMessage = { + role: "user"; + id: string; + content: string | (TextContent | ImageContent)[]; + timestamp: number; +}; + +export type AssistantMessage = { + role: "assistant"; + content: (TextContent | ThinkingContent | ToolCall | HostedSearchContent)[]; + api: string; + provider: string; + model: string; + responseId?: string; + usage: Usage; + stopReason: StopReason; + errorMessage?: string; + timestamp: number; +}; + +export type ToolResultMessage = { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: (TextContent | ImageContent)[]; + details?: TDetails; + isError: boolean; + timestamp: number; +}; + +export type Message = UserMessage | AssistantMessage | ToolResultMessage; + +export type Tool = { + name: string; + description: string; + parameters: TParameters; +}; + +export type Context = { + systemPrompt?: string; + messages: Message[]; + tools?: Tool[]; +}; diff --git a/crates/agent-mobile/src/lib/automation/backend.ts b/crates/agent-mobile/src/lib/automation/backend.ts new file mode 100644 index 000000000..79f992ace --- /dev/null +++ b/crates/agent-mobile/src/lib/automation/backend.ts @@ -0,0 +1,82 @@ +// Web transport for the automation store: every call is relayed through the +// gateway's generic cron.manage RPC to the desktop-authoritative +// AutomationStore. Change events arrive via the settings-sync snapshot +// (automationCron / automationHooks fields), which useGatewaySettingsSync +// feeds into the store — so subscribe() has nothing to wire up here. This +// file is the per-platform adapter — the desktop frontend ships its own copy +// speaking Tauri invoke. + +import type { + AutomationApplyInput, + AutomationSnapshot, + CronApplyResponse, + CronRunNowResponse, + CronRunRecord, + CronSnapshot, + HooksApplyResponse, + HooksSnapshot, +} from "@liveagent/ui/lib/automation/types"; +import { getGatewayWebSocketClient } from "../gatewaySocket"; +import { loadToken } from "../storage"; + +export type AutomationBackendHandlers = { + onCron: (snapshot: CronSnapshot) => void; + onHooks: (snapshot: HooksSnapshot) => void; +}; + +async function cronManage(action: string, taskId?: string, payload?: unknown): Promise { + const response = await getGatewayWebSocketClient(loadToken().trim()).cronManage({ + action, + task_id: taskId, + task_json: payload === undefined ? undefined : JSON.stringify(payload), + }); + try { + return JSON.parse(response.result_json) as T; + } catch (error) { + throw new Error( + error instanceof Error && error.message.trim() + ? `Automation response is not valid JSON: ${error.message.trim()}` + : "Automation response is not valid JSON", + ); + } +} + +export const backend = { + fetchSnapshot(): Promise { + return cronManage("snapshot"); + }, + + cronApply(input: AutomationApplyInput): Promise { + return cronManage("cron_apply", undefined, input); + }, + + hooksApply(input: AutomationApplyInput): Promise { + return cronManage("hooks_apply", undefined, input); + }, + + async listRuns(taskId: string, limit?: number): Promise { + const payload = await cronManage<{ runs?: CronRunRecord[] }>("list_runs", taskId, { + limit: limit ?? 100, + }); + return Array.isArray(payload.runs) ? payload.runs : []; + }, + + async clearRuns(taskId: string): Promise { + const payload = await cronManage<{ clearedCount?: number }>("clear_runs", taskId); + return typeof payload.clearedCount === "number" ? payload.clearedCount : 0; + }, + + runNow(taskId: string): Promise { + return cronManage("run_now", taskId); + }, + + async validateCronExpression(expression: string): Promise { + await cronManage("validate", undefined, { expression }); + }, + + subscribe(_handlers: AutomationBackendHandlers): () => void { + // Snapshots are pushed through the gateway settings-sync channel and fed + // into the store by useGatewaySettingsSync. + return () => {}; + }, +}; diff --git a/crates/agent-mobile/src/lib/chat/askUserQuestionBridge.ts b/crates/agent-mobile/src/lib/chat/askUserQuestionBridge.ts new file mode 100644 index 000000000..76905137a --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/askUserQuestionBridge.ts @@ -0,0 +1,27 @@ +// WebUI 端 AskUserQuestion 应答桥:卡片位于 transcript 深处,提交动作由 +// GatewayApp 注册(经 gateway chat_queue.tool_answer 送达桌面端工具挂起表)。 +// 模块级单例避免跨 5 层组件做 props 透传,模式同 uploadedImagePreview 缓存。 +import type { AskUserQuestionAnswer } from "@liveagent/ui/lib/chat/askUserQuestion"; + +export type AskUserQuestionSubmitOutcome = { ok: boolean; message?: string }; + +type AskUserQuestionAnswerHandler = ( + toolCallId: string, + answers: AskUserQuestionAnswer[], +) => Promise; + +let handler: AskUserQuestionAnswerHandler | null = null; + +export function registerAskUserQuestionAnswerHandler(next: AskUserQuestionAnswerHandler | null) { + handler = next; +} + +export function submitAskUserQuestionAnswer( + toolCallId: string, + answers: AskUserQuestionAnswer[], +): Promise { + if (!handler) { + return Promise.resolve({ ok: false, message: "Gateway connection is not ready." }); + } + return handler(toolCallId, answers); +} diff --git a/crates/agent-mobile/src/lib/chat/chatHistory.ts b/crates/agent-mobile/src/lib/chat/chatHistory.ts new file mode 100644 index 000000000..7b8288d7a --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/chatHistory.ts @@ -0,0 +1,6 @@ +// Conversation summary shape shared with the gateway's history.list / +// history.event payloads. The webui only consumes summaries — transcript +// content arrives as messages_json parsed by lib/chatUi. The canonical shape +// lives in the mirrored sidebar state layer; this alias keeps the historical +// name for the web components that render summaries. +export type { SidebarConversation as ChatHistorySummary } from "@liveagent/ui/lib/sidebar/types"; diff --git a/crates/agent-mobile/src/lib/chat/chatPageHelpers.ts b/crates/agent-mobile/src/lib/chat/chatPageHelpers.ts new file mode 100644 index 000000000..d33037534 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/chatPageHelpers.ts @@ -0,0 +1,10 @@ +export { + buildModelOptions, + groupModelOptionsByProvider, + type ModelOptionGroup, + type ProviderSortMode, + persistProviderSortMode, + readStoredProviderSortMode, + sortModelOptionGroups, +} from "@liveagent/ui/lib/models/modelOptions"; +export { isAbortLikeError } from "@liveagent/ui/lib/providers/errorMessage"; diff --git a/crates/agent-mobile/src/lib/chat/conversationState.ts b/crates/agent-mobile/src/lib/chat/conversationState.ts new file mode 100644 index 000000000..7ff7f4d6d --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/conversationState.ts @@ -0,0 +1,7 @@ +// Stable identity of a persisted user message, used to anchor edit-resend +// (`chat.edit_resend` base_message_ref, `rebased` truncation events). Wire +// form is snake_case (see shared buildHistoryMessageRefPayload); the +// desktop validates segment_id + message_id + role + content_hash before +// truncating. The full segmented conversation state machine lives in +// agent-gui — the webui renders from its TranscriptStore instead. +export type { HistoryMessageRef } from "@liveagent/ui/lib/chat/historyMessageRef"; diff --git a/crates/agent-mobile/src/lib/chat/historyWindow.ts b/crates/agent-mobile/src/lib/chat/historyWindow.ts new file mode 100644 index 000000000..2cdd16ab6 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/historyWindow.ts @@ -0,0 +1,189 @@ +// Lazy history-window bookkeeping for opening/refreshing a conversation. +// +// The web end renders a conversation from tail windows of the persisted +// history (`history.get` with `max_messages`). Instead of hydrating the full +// conversation at idle — which makes open cost and per-turn quiet-refresh +// cost proportional to the conversation's lifetime size — the app tracks, +// per conversation, the oldest persisted message it has loaded: +// +// oldestOffset — number of persisted messages ABOVE the loaded window +// (0 means the window starts at the very first message) +// lastTotal — the freshest total_message_count this client observed: +// the latest applied fetch, bumped forward by history-upsert +// summary counts (noteHistoryWindowTotal) +// +// Requests are EDGE-ANCHORED: a quiet refresh asks for +// `lastTotal - oldestOffset` messages, so the window always spans everything +// persisted after the established edge (covering every exchange streamed +// this session — alignEnrich requires the window to reach the live turns) +// while messages above the edge stay unfetched until the user pages up. +// "Load earlier" grows the request beyond the edge by a page. +// +// Because the tail window is anchored to the END of the conversation, +// concurrent appends between "compute request size" and "serve response" +// SLIP the returned window's top edge below the established one. Applying a +// slipped window could hand alignEnrich a window that no longer covers the +// rendered region's coverage counts (its count-coincidence full-window branch +// could then rebuild the region without the rows above the slipped edge), so +// slipped responses are never applied: the caller retries once with the +// corrected size from the response's authoritative total, and skips the +// cycle when the edge slipped again (the app's quiet-refresh loops converge +// on a later pass). +// +// The slip retry is the SAFETY NET, not the steady state: the desktop +// persists a run's messages before it reports completion, and the history +// upsert it publishes at that flush carries the new total. Folding that +// total into `lastTotal` (noteHistoryWindowTotal) before the busy→idle +// refresh plans its span makes the common post-turn refresh a single +// request; only a lossy/dropped upsert (they are best-effort broadcasts) +// leaves a stale span behind for the retry to correct. + +export type HistoryWindowState = { + /** Persisted messages above the loaded window (0 = window starts at message 0). */ + oldestOffset: number; + /** Freshest observed total: latest applied response, bumped by upsert counts. */ + lastTotal: number; +}; + +export type HistoryWindowCounts = { + totalMessageCount: number; + returnedMessageCount: number; + hasMore: boolean; +}; + +export type HistoryWindowVerdict = + | { action: "apply"; nextState: HistoryWindowState } + | { action: "retry"; retryMaxMessages: number }; + +function asNonNegativeInt(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; + } + return Math.floor(value); +} + +// Extracts usable window counts from a HistoryDetail-shaped response. Returns +// null when the producer did not report counts (defensive: bookkeeping is +// then dropped and the conversation falls back to plain tail-window fetches). +export function readHistoryWindowCounts(detail: { + total_message_count?: number; + returned_message_count?: number; + has_more?: boolean; +}): HistoryWindowCounts | null { + const total = asNonNegativeInt(detail.total_message_count); + const returned = asNonNegativeInt(detail.returned_message_count); + if (total === null || returned === null || total === 0 || returned === 0 || returned > total) { + return null; + } + return { + totalMessageCount: total, + returnedMessageCount: returned, + hasMore: detail.has_more === true, + }; +} + +// Computes the max_messages for the next history fetch. `undefined` means +// fetch the full conversation (no window cap). +export function planHistoryWindowRequest( + state: HistoryWindowState | undefined, + options: { + initialWindowMessages: number; + extendMessages?: number; + }, +): number | undefined { + const extend = Math.max(0, Math.floor(options.extendMessages ?? 0)); + if (!state) { + return options.initialWindowMessages + extend; + } + if (state.oldestOffset <= 0) { + // The window already reaches the first message: the conversation is + // fully loaded and stays fully loaded (mirrors the pre-lazy behavior + // after a full hydration). + return undefined; + } + const spanned = Math.max(0, state.lastTotal - state.oldestOffset); + return Math.max(options.initialWindowMessages, spanned) + extend; +} + +// State adopted from an applied response. +export function adoptHistoryWindowState(counts: HistoryWindowCounts): HistoryWindowState { + const edge = counts.hasMore + ? Math.max(0, counts.totalMessageCount - counts.returnedMessageCount) + : 0; + return { oldestOffset: edge, lastTotal: counts.totalMessageCount }; +} + +// Folds an out-of-band total — a history upsert's summary message_count, the +// same total_message_count authority the fetch responses report — into the +// bookkeeping, so the next planned span already covers messages appended +// since the last applied fetch. Without this, every post-turn quiet refresh +// under-requests by exactly the flushed message count and burns a slipped- +// edge retry (two full window fetches per turn instead of one). +// +// Totals only move forward: a stale/invalid count returns the state +// untouched (identity — callers can skip the write), and truncations +// (edit-resend) converge through the fetch path, whose complete responses +// re-establish both fields authoritatively. +export function noteHistoryWindowTotal( + state: HistoryWindowState, + totalMessageCount: unknown, +): HistoryWindowState { + const total = asNonNegativeInt(totalMessageCount); + if (total === null || total <= state.lastTotal) { + return state; + } + return { oldestOffset: state.oldestOffset, lastTotal: total }; +} + +// Decides whether a windowed response is safe to apply. See the module +// docblock for why slipped edges must not be applied. Callers retry at most +// once; a second "retry" verdict means "skip this cycle". +export function evaluateHistoryWindowResponse(params: { + previous: HistoryWindowState | undefined; + counts: HistoryWindowCounts; + extendMessages?: number; +}): HistoryWindowVerdict { + const { previous, counts } = params; + const nextState = adoptHistoryWindowState(counts); + if (!previous || !counts.hasMore) { + // First observation establishes the edge; a complete fetch is always safe. + return { action: "apply", nextState }; + } + if (nextState.oldestOffset <= previous.oldestOffset) { + return { action: "apply", nextState }; + } + // The top edge slipped below the established one (the conversation grew + // while the request was in flight): re-request anchored on the response's + // authoritative total. + const extend = Math.max(0, Math.floor(params.extendMessages ?? 0)); + const retryMaxMessages = Math.max(1, counts.totalMessageCount - previous.oldestOffset + extend); + return { action: "retry", retryMaxMessages }; +} + +// Drops leading window entries that belong to a turn whose user message sits +// above the fetched window (windows with has_more only). Keeping the top of +// the rendered region aligned to a turn boundary keeps every parsed entry id +// stable across "load earlier" expansions: headless entries take their ids +// from a window-relative anchor ("ht:^>N") and would be re-keyed once their +// real user anchor scrolls into the window, which would break the +// virtualizer's keyed scroll anchoring and re-mount those rows. +// +// Leading checkpoint (compaction summary) entries are kept: their ids derive +// from the persisted summary id (window-position independent), and dropping +// them would hide the compaction marker for conversations whose window opens +// inside a compacted segment. +export function trimLeadingHeadlessEntries(entries: T[]): T[] { + const firstUserIndex = entries.findIndex((entry) => entry.kind === "user"); + if (firstUserIndex < 0) { + // No user anchor in the whole window (one exchange larger than the + // window): render as-is rather than blanking the transcript. + return entries; + } + if (firstUserIndex === 0) { + return entries; + } + const kept = entries.filter( + (entry, index) => index >= firstUserIndex || entry.kind === "checkpoint", + ); + return kept.length === entries.length ? entries : kept; +} diff --git a/crates/agent-mobile/src/lib/chat/runtimeCompatibility.ts b/crates/agent-mobile/src/lib/chat/runtimeCompatibility.ts new file mode 100644 index 000000000..695a6352d --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/runtimeCompatibility.ts @@ -0,0 +1,9 @@ +import type { AgentStatus } from "@/lib/gatewayTypes"; + +export const CHAT_RUNTIME_PROTOCOL_INCOMPATIBLE = "protocol_incompatible"; + +export function isChatRuntimeProtocolIncompatible(status: AgentStatus | null | undefined): boolean { + return ( + status?.online === true && status.runtime_state?.trim() === CHAT_RUNTIME_PROTOCOL_INCOMPATIBLE + ); +} diff --git a/crates/agent-mobile/src/lib/chat/stream/activityStore.ts b/crates/agent-mobile/src/lib/chat/stream/activityStore.ts new file mode 100644 index 000000000..58825e296 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/stream/activityStore.ts @@ -0,0 +1,222 @@ +import type { ConversationActivityEvent, RunActivityState } from "./streamTypes"; + +// The single source of truth for "which conversations have an active run": +// sidebar dots, busy indicators, everything. Fed by the always-on +// chat.activity broadcast plus history.list hydration — both carry run ids +// composed by the gateway inside the run-lifecycle transition, so there is no +// enrichment race and no local/remote union to reconcile. + +export type ConversationActivity = { + runId: string; + state: RunActivityState; + workdir: string | null; + updatedAt: number; + // Local receipt time (store clock): lets hydrate distinguish a push that + // raced ahead of a list snapshot from a genuinely stale entry. + receivedAt: number; +}; + +// A push received this recently survives a non-empty hydrate batch that omits +// it: the batch snapshot may have been built just before the run registered. +// Stale (stuck) entries are far older than this and still get dropped. +const RECENT_PUSH_KEEP_MS = 15_000; + +export type ActivitySnapshot = { + activities: ReadonlyMap; + revision: number; +}; + +export type ActivityStore = { + getSnapshot(): ActivitySnapshot; + subscribe(listener: () => void): () => void; + isRunning(conversationId: string): boolean; + get(conversationId: string): ConversationActivity | null; + applyActivityEvent(event: ConversationActivityEvent): void; + // Local settlement by run identity: a run_finished (or an activity-less + // subscribe sync) proves the stored run ended even when the chat.activity + // "stopped" broadcast was missed. Only the exact run is cleared — a newer + // run's entry is never touched. + settleRun(conversationId: string, runId: string): void; + // history.list / chat.activities hydration: authoritative snapshot of + // every active run at response time. Entries absent from the batch are + // dropped unless listed in keepConversationIds (locally pending commands + // the gateway may not know about yet). + hydrate( + items: Array<{ + conversationId: string; + runId: string; + state?: string; + workdir?: string | null; + updatedAt?: number; + }>, + options?: { keepConversationIds?: ReadonlySet }, + ): void; + clear(): void; +}; + +export function createActivityStore(options?: { now?: () => number }): ActivityStore { + const now = options?.now ?? Date.now; + let activities = new Map(); + let snapshot: ActivitySnapshot = { activities, revision: 0 }; + const listeners = new Set<() => void>(); + + const emit = () => { + snapshot = { activities, revision: snapshot.revision + 1 }; + for (const listener of listeners) { + listener(); + } + }; + + const normalizeState = (state: string | undefined | null): RunActivityState => { + return state === "queued" || state === "cancelling" ? state : "running"; + }; + + return { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + isRunning: (conversationId) => activities.has(conversationId), + get: (conversationId) => activities.get(conversationId) ?? null, + + applyActivityEvent: (event) => { + const current = activities.get(event.conversationId); + // Activity events are state signals ordered per conversation by the + // gateway; a stale timestamp can only appear after a reconnect race — + // ignore anything older than what we already show. + if (current && event.updatedAt > 0 && event.updatedAt < current.updatedAt) { + return; + } + if (!event.running || !event.runId) { + if (!activities.has(event.conversationId)) { + return; + } + activities = new Map(activities); + activities.delete(event.conversationId); + emit(); + return; + } + const next: ConversationActivity = { + runId: event.runId, + state: event.state ?? "running", + workdir: event.workdir, + updatedAt: event.updatedAt, + receivedAt: now(), + }; + if ( + current && + current.runId === next.runId && + current.state === next.state && + current.workdir === next.workdir + ) { + return; + } + activities = new Map(activities); + activities.set(event.conversationId, next); + emit(); + }, + + settleRun: (conversationId, runId) => { + if (!runId) { + return; + } + const current = activities.get(conversationId); + if (!current || current.runId !== runId) { + return; + } + activities = new Map(activities); + activities.delete(conversationId); + emit(); + }, + + hydrate: (items, hydrateOptions) => { + const nowMs = now(); + const incoming = new Map(); + for (const item of items) { + const conversationId = item.conversationId.trim(); + const runId = item.runId.trim(); + if (!conversationId || !runId) { + continue; + } + incoming.set(conversationId, { + runId, + state: normalizeState(item.state), + workdir: item.workdir?.trim() || null, + updatedAt: item.updatedAt ?? 0, + receivedAt: nowMs, + }); + } + + // An empty authoritative snapshot means idle everywhere. + if (incoming.size === 0) { + if (activities.size === 0) { + return; + } + activities = new Map(); + emit(); + return; + } + + // The snapshot races the chat.activity pushes: merge per entry with + // newer-wins (all timestamps come from the gateway clock) so a stale + // list response cannot resurrect a run we already saw finish. Entries + // absent from the authoritative batch are dropped — the gateway says + // they are not running — except conversations with a locally pending + // command (whose run the gateway may not have registered yet) and + // entries whose push arrived after the batch snapshot was built. + const merged = new Map(); + for (const [conversationId, activity] of incoming) { + const current = activities.get(conversationId); + merged.set( + conversationId, + current && current.updatedAt > activity.updatedAt ? current : activity, + ); + } + const keepConversationIds = hydrateOptions?.keepConversationIds; + for (const [conversationId, current] of activities) { + if (merged.has(conversationId)) { + continue; + } + if ( + keepConversationIds?.has(conversationId) || + nowMs - current.receivedAt <= RECENT_PUSH_KEEP_MS + ) { + merged.set(conversationId, current); + } + } + + let changed = merged.size !== activities.size; + if (!changed) { + for (const [conversationId, activity] of merged) { + const current = activities.get(conversationId); + if ( + !current || + current.runId !== activity.runId || + current.state !== activity.state || + current.workdir !== activity.workdir || + current.updatedAt !== activity.updatedAt + ) { + changed = true; + break; + } + } + } + if (!changed) { + return; + } + activities = merged; + emit(); + }, + + clear: () => { + if (activities.size === 0) { + return; + } + activities = new Map(); + emit(); + }, + }; +} diff --git a/crates/agent-mobile/src/lib/chat/stream/chatCommandPipeline.ts b/crates/agent-mobile/src/lib/chat/stream/chatCommandPipeline.ts new file mode 100644 index 000000000..88ecb8d98 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/stream/chatCommandPipeline.ts @@ -0,0 +1,288 @@ +import type { HistoryMessageRef } from "../conversationState"; +import type { TranscriptStore } from "../transcript/transcriptStore"; +import type { ChatCommandAccepted, ChatCommandUpdate } from "./streamTypes"; + +// Command lifecycle for chat submissions from this client. The pipeline owns +// the optimistic user echo and reacts to pre-stream outcomes +// (chat.command_update); everything after run start flows through the +// conversation stream and needs no pipeline involvement. + +export type ChatCommandRequest = { + conversationId: string; + clientRequestId: string; + message: string; + attachments?: Extract< + Parameters[0]["attachments"], + unknown + >; + // edit_resend commands apply the truncation and replacement bubble in the + // same optimistic transcript commit. Compensation paths + // (queued_in_gui / failed) need to know to restore the persisted suffix. + isEditResend?: boolean; + baseMessageRef?: HistoryMessageRef; + // Queue-destined submissions (a run is already streaming) skip the local + // echo: the prompt belongs in the queue panel, and a transcript bubble + // would just flash until the queued_in_gui compensation removed it. + optimistic?: boolean; + submit: () => Promise; +}; + +export type PendingChatCommand = { + runId: string | null; + clientRequestId: string; + conversationId: string; + isEditResend: boolean; + submittedAt: number; +}; + +export type ChatCommandOutcome = + | { kind: "accepted"; accepted: ChatCommandAccepted } + | { kind: "queued_in_gui"; update: ChatCommandUpdate } + | { kind: "bound"; update: ChatCommandUpdate } + | { kind: "settled" } + | { kind: "failed"; errorCode: string | null; message: string }; + +export type ChatCommandPipelineHooks = { + getTranscriptStore(conversationId: string): TranscriptStore; + // A draft conversation got its real id: re-key stores/subscriptions. + onBound?(update: ChatCommandUpdate, pending: PendingChatCommand): void; + onQueuedInGui?(update: ChatCommandUpdate, pending: PendingChatCommand): void; + onFailed?(pending: PendingChatCommand, errorCode: string | null, message: string): void; + onPendingChanged?(): void; +}; + +const PENDING_COMMAND_TIMEOUT_MS = 60_000; + +export class ChatCommandPipeline { + // conversationId → pending command (one in-flight submission per + // conversation drives the pre-first-token spinner). + private pending = new Map(); + private byRunId = new Map(); + private timeouts = new Map>(); + private settledOutcomes = new WeakMap(); + + constructor(private readonly hooks: ChatCommandPipelineHooks) {} + + hasPending(conversationId: string): boolean { + return this.pending.has(conversationId); + } + + // Conversations with an in-flight submission — activity hydration must not + // drop these even when an authoritative snapshot does not list them yet. + pendingConversationIds(): Set { + return new Set(this.pending.keys()); + } + + reset(): void { + for (const pending of this.pending.values()) { + const store = this.hooks.getTranscriptStore(pending.conversationId); + if (pending.isEditResend) { + store.restoreEditResendTranscript?.(pending.clientRequestId); + } + store.removeOptimisticUserEntry(pending.clientRequestId); + this.settledOutcomes.set(pending, { kind: "settled" }); + } + for (const timeout of this.timeouts.values()) { + clearTimeout(timeout); + } + this.pending.clear(); + this.byRunId.clear(); + this.timeouts.clear(); + this.hooks.onPendingChanged?.(); + } + + async submit(request: ChatCommandRequest): Promise { + if (request.optimistic !== false) { + this.hooks.getTranscriptStore(request.conversationId).addOptimisticUserEntry({ + clientRequestId: request.clientRequestId, + text: request.message, + attachments: request.attachments as never, + baseMessageRef: request.baseMessageRef, + }); + } + + const pending: PendingChatCommand = { + runId: null, + clientRequestId: request.clientRequestId, + conversationId: request.conversationId, + isEditResend: request.isEditResend === true, + submittedAt: Date.now(), + }; + this.setPending(request.conversationId, pending); + + try { + const accepted = await request.submit(); + pending.runId = accepted.runId; + const settledOutcome = this.settledOutcomes.get(pending); + if (settledOutcome) { + return settledOutcome; + } + if (this.pending.get(pending.conversationId) === pending) { + // Only register while the pending is still live: an own run signal + // (matched by client_request_id) may have settled it before the + // accept response landed, and a dead registration would leak. + this.byRunId.set(accepted.runId, pending); + } + return { kind: "accepted", accepted }; + } catch (error) { + const settledOutcome = this.settledOutcomes.get(pending); + if (settledOutcome) { + return settledOutcome; + } + const message = error instanceof Error ? error.message : "chat command failed"; + return this.fail(pending, null, message); + } + } + + // chat.command_update push from the gateway (issuing connection only). + handleCommandUpdate(update: ChatCommandUpdate): void { + let pending = this.byRunId.get(update.runId); + if (!pending && update.clientRequestId) { + // The update rides the gateway's priority control queue and can + // overtake the chat.command accept response on a congested link, so + // the run id may not be bound yet. Adopt it — but only onto a pending + // that is still unbound and proves ownership by its own client id + // (mirrors handleRunSignal's strict matching). + for (const candidate of this.pending.values()) { + if (candidate.runId === null && candidate.clientRequestId === update.clientRequestId) { + candidate.runId = update.runId; + this.byRunId.set(update.runId, candidate); + pending = candidate; + break; + } + } + } + if (!pending) { + return; + } + switch (update.phase) { + case "bound": { + if (update.conversationId && update.conversationId !== pending.conversationId) { + // Draft conversation materialized: the app re-keys stores and + // subscriptions, then the pending command follows the real id. + const previousConversationId = pending.conversationId; + pending.conversationId = update.conversationId; + this.movePending(previousConversationId, update.conversationId, pending); + this.hooks.onBound?.(update, pending); + } + return; + } + case "queued_in_gui": { + // The prompt is parked in the desktop queue: it is not pending + // anymore, the queue panel shows it. The seeded entries are removed + // by the stream's run_queued event; the optimistic entry may still + // exist if the seed never reached the stream (draft conversations). + const store = this.hooks.getTranscriptStore(pending.conversationId); + if (pending.isEditResend) { + // Server-side history is unchanged for a parked edit: restore the + // optimistically truncated suffix locally right away; the hook's + // history refresh remains the authoritative reconciliation. + store.restoreEditResendTranscript?.(pending.clientRequestId); + } + store.removeOptimisticUserEntry(pending.clientRequestId); + const outcome: ChatCommandOutcome = { kind: "queued_in_gui", update }; + this.settledOutcomes.set(pending, outcome); + this.clearPending(pending); + this.hooks.onQueuedInGui?.(update, pending); + return; + } + case "failed": { + this.fail(pending, update.errorCode, update.message ?? "chat command failed"); + return; + } + } + } + + // Stream/activity signals that settle the pending command: the run started + // (tokens will flow), finished (failed fast), or was queued. Identity is + // strict — a null-runId pending (accept response still in flight) settles + // only on a signal carrying its own client_request_id; foreign runs (GUI + // queue auto-sends, another client's commands) never disarm the watchdog. + handleRunSignal(conversationId: string, runId: string, clientRequestId?: string): void { + const pending = this.pending.get(conversationId); + if (!pending) { + return; + } + const matchesRunId = runId !== "" && pending.runId === runId; + const matchesClientRequest = + pending.runId === null && + typeof clientRequestId === "string" && + clientRequestId !== "" && + clientRequestId === pending.clientRequestId; + if (matchesRunId || matchesClientRequest) { + this.settledOutcomes.set(pending, { kind: "settled" }); + this.clearPending(pending); + } + } + + private fail( + pending: PendingChatCommand, + errorCode: string | null, + message: string, + ): Extract { + const outcome = { kind: "failed" as const, errorCode, message }; + this.settledOutcomes.set(pending, outcome); + const store = this.hooks.getTranscriptStore(pending.conversationId); + if (pending.isEditResend) { + // Offline-safe compensation: the command never ran, so the transcript + // stashed before the optimistic truncation is still authoritative. The + // onFailed hook's history refresh reconciles when the network works. + store.restoreEditResendTranscript?.(pending.clientRequestId); + } + store.removeOptimisticUserEntry(pending.clientRequestId); + store.appendLocalError(message); + this.clearPending(pending); + this.hooks.onFailed?.(pending, errorCode, message); + return outcome; + } + + private setPending(conversationId: string, pending: PendingChatCommand): void { + const existingTimeout = this.timeouts.get(conversationId); + if (existingTimeout !== undefined) { + clearTimeout(existingTimeout); + } + this.pending.set(conversationId, pending); + this.timeouts.set( + conversationId, + setTimeout(() => { + const current = this.pending.get(pending.conversationId); + if (current === pending) { + this.fail( + pending, + "startup_timeout", + "The desktop app did not start this request in time. Please retry.", + ); + } + }, PENDING_COMMAND_TIMEOUT_MS), + ); + this.hooks.onPendingChanged?.(); + } + + private movePending(from: string, to: string, pending: PendingChatCommand): void { + if (this.pending.get(from) === pending) { + this.pending.delete(from); + } + const timeout = this.timeouts.get(from); + if (timeout !== undefined) { + this.timeouts.delete(from); + this.timeouts.set(to, timeout); + } + this.pending.set(to, pending); + this.hooks.onPendingChanged?.(); + } + + private clearPending(pending: PendingChatCommand): void { + if (this.pending.get(pending.conversationId) === pending) { + this.pending.delete(pending.conversationId); + const timeout = this.timeouts.get(pending.conversationId); + if (timeout !== undefined) { + clearTimeout(timeout); + this.timeouts.delete(pending.conversationId); + } + } + if (pending.runId) { + this.byRunId.delete(pending.runId); + } + this.hooks.onPendingChanged?.(); + } +} diff --git a/crates/agent-mobile/src/lib/chat/stream/conversationStreamClient.ts b/crates/agent-mobile/src/lib/chat/stream/conversationStreamClient.ts new file mode 100644 index 000000000..f39463af9 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/stream/conversationStreamClient.ts @@ -0,0 +1,312 @@ +import type { + ConversationStreamEvent, + ConversationStreamHandlers, + ConversationSubscribeResult, +} from "./streamTypes"; +import { normalizeSubscribeResult, readEventConversationId, readEventSeq } from "./streamTypes"; + +// Transport-owned subscription manager: one persistent registration per +// conversation. Resume is built in — on every (re)connect the manager +// re-issues chat.subscribe with the last seen seq and stream epoch, so a +// dropped socket never silently ends a stream. Registrations survive +// disconnects and are removed only by their cleanup function. + +type StreamTransport = { + request(type: string, payload: unknown, options?: { timeoutMs?: number }): Promise; +}; + +type Registration = { + conversationId: string; + handlers: ConversationStreamHandlers; + lastSeq: number; + streamEpoch: string; + synced: boolean; + syncing: boolean; + resyncQueued: boolean; + disposed: boolean; + retryAttempt: number; + retryTimer: ReturnType | null; + // Live events that raced ahead of the chat.subscribe response; drained + // after onSync (seq dedup drops the replay overlap). + pendingEvents: ConversationStreamEvent[]; +}; + +const MAX_PENDING_EVENTS = 512; +const SUBSCRIBE_REQUEST_TIMEOUT_MS = 30_000; +const SYNC_RETRY_INITIAL_DELAY_MS = 250; +// Persistent failures (deleted conversation, permanent server-side rejects) +// retry forever but back off far enough to stay negligible; transient +// outages still recover within the first fast attempts. +const SYNC_RETRY_MAX_DELAY_MS = 30_000; +const SYNC_RETRY_MAX_EXPONENT = 7; + +export class ConversationStreamClient { + private registrations = new Map(); + private connected = false; + private connectionGeneration = 0; + + constructor(private readonly transport: StreamTransport) {} + + get size(): number { + return this.registrations.size; + } + + subscribe(conversationId: string, handlers: ConversationStreamHandlers): () => void { + const normalized = conversationId.trim(); + if (!normalized) { + return () => {}; + } + const previous = this.registrations.get(normalized); + if (previous) { + previous.disposed = true; + this.clearRetry(previous); + } + const registration: Registration = { + conversationId: normalized, + handlers, + lastSeq: 0, + streamEpoch: "", + synced: false, + syncing: false, + resyncQueued: false, + disposed: false, + retryAttempt: 0, + retryTimer: null, + pendingEvents: [], + }; + this.registrations.set(normalized, registration); + if (this.connected) { + void this.sync(registration); + } + return () => { + registration.disposed = true; + this.clearRetry(registration); + if (this.registrations.get(normalized) === registration) { + this.registrations.delete(normalized); + if (this.connected) { + void this.transport + .request("chat.unsubscribe", { conversation_id: normalized }) + .catch(() => undefined); + } + } + }; + } + + // The socket authenticated (first connect or reconnect): (re)issue + // chat.subscribe for every registration with its resume cursor. + handleConnected(): void { + if (this.connected) { + return; + } + this.connected = true; + this.connectionGeneration += 1; + for (const registration of this.registrations.values()) { + this.clearRetry(registration); + registration.retryAttempt = 0; + registration.synced = false; + void this.sync(registration); + } + } + + handleDisconnected(): void { + if (this.connected) { + this.connectionGeneration += 1; + } + this.connected = false; + for (const registration of this.registrations.values()) { + this.clearRetry(registration); + registration.synced = false; + registration.resyncQueued = false; + // Events buffered on the dead connection belong to its stream epoch; + // the resume protocol re-fetches everything after lastSeq on + // reconnect, so draining them later could only corrupt the transcript. + registration.pendingEvents = []; + } + } + + // Server pushed chat.event: route by conversation id, advance the cursor. + handleChatEvent(payload: unknown): void { + if (!payload || typeof payload !== "object") { + return; + } + const event = payload as ConversationStreamEvent; + const conversationId = readEventConversationId(event); + if (!conversationId) { + return; + } + const registration = this.registrations.get(conversationId); + if (!registration || registration.disposed) { + return; + } + if (!registration.synced) { + // Live events published after the server registered the subscriber can + // arrive before the subscribe response; buffer and drain after onSync. + if (registration.pendingEvents.length < MAX_PENDING_EVENTS) { + registration.pendingEvents.push(event); + } + return; + } + this.deliver(registration, event); + } + + private deliver(registration: Registration, event: ConversationStreamEvent): void { + const seq = readEventSeq(event); + if (seq > 0) { + if (seq <= registration.lastSeq) { + return; + } + if (registration.lastSeq > 0 && seq > registration.lastSeq + 1) { + // Missed events on the wire — resync from the cursor. + void this.sync(registration); + return; + } + registration.lastSeq = seq; + } + registration.handlers.onEvent(event); + } + + // App-requested resync (e.g. transcript divergence): re-issue + // chat.subscribe from the cursor, exactly like gap recovery. No-op when + // the conversation is not subscribed or the socket is down (reconnect + // resume covers that case). + resync(conversationId: string): void { + const registration = this.registrations.get(conversationId.trim()); + if (!registration || registration.disposed || !this.connected) { + return; + } + this.clearRetry(registration); + void this.sync(registration); + } + + // Server told us our subscriber overflowed: resume from the cursor. + handleSubscriptionReset(payload: unknown): void { + if (!payload || typeof payload !== "object") { + return; + } + const conversationId = + typeof (payload as { conversation_id?: unknown }).conversation_id === "string" + ? ((payload as { conversation_id: string }).conversation_id ?? "").trim() + : ""; + const registration = conversationId ? this.registrations.get(conversationId) : undefined; + if (registration && !registration.disposed) { + this.clearRetry(registration); + void this.sync(registration); + } + } + + private async sync(registration: Registration): Promise { + if ( + !this.connected || + registration.disposed || + this.registrations.get(registration.conversationId) !== registration + ) { + return; + } + if (registration.syncing) { + registration.resyncQueued = true; + return; + } + this.clearRetry(registration); + registration.syncing = true; + const connectionGeneration = this.connectionGeneration; + let shouldRetry = false; + try { + const raw = await this.transport.request( + "chat.subscribe", + { + conversation_id: registration.conversationId, + after_seq: registration.lastSeq, + stream_epoch: registration.streamEpoch || undefined, + }, + { + timeoutMs: SUBSCRIBE_REQUEST_TIMEOUT_MS, + }, + ); + if ( + registration.disposed || + !this.connected || + connectionGeneration !== this.connectionGeneration || + this.registrations.get(registration.conversationId) !== registration + ) { + return; + } + const result: ConversationSubscribeResult = normalizeSubscribeResult( + registration.conversationId, + raw, + ); + registration.streamEpoch = result.streamEpoch; + registration.lastSeq = result.latestSeq; + registration.synced = true; + registration.retryAttempt = 0; + registration.handlers.onSync(result); + const pending = registration.pendingEvents; + registration.pendingEvents = []; + for (const event of pending) { + if (registration.disposed || !registration.synced) { + break; + } + this.deliver(registration, event); + } + } catch { + if ( + !registration.disposed && + this.connected && + connectionGeneration === this.connectionGeneration && + this.registrations.get(registration.conversationId) === registration + ) { + registration.synced = false; + shouldRetry = true; + } + } finally { + registration.syncing = false; + if ( + registration.resyncQueued && + !registration.disposed && + this.connected && + this.registrations.get(registration.conversationId) === registration + ) { + registration.resyncQueued = false; + void this.sync(registration); + } else if (shouldRetry) { + this.scheduleRetry(registration); + } + } + } + + private scheduleRetry(registration: Registration): void { + if ( + registration.retryTimer !== null || + registration.disposed || + !this.connected || + this.registrations.get(registration.conversationId) !== registration + ) { + return; + } + const baseDelay = Math.min( + SYNC_RETRY_MAX_DELAY_MS, + SYNC_RETRY_INITIAL_DELAY_MS * + 2 ** Math.min(registration.retryAttempt, SYNC_RETRY_MAX_EXPONENT), + ); + registration.retryAttempt += 1; + const jitter = Math.floor(Math.random() * Math.min(250, Math.max(1, baseDelay / 2))); + registration.retryTimer = setTimeout(() => { + registration.retryTimer = null; + if ( + registration.disposed || + !this.connected || + this.registrations.get(registration.conversationId) !== registration + ) { + return; + } + void this.sync(registration); + }, baseDelay + jitter); + } + + private clearRetry(registration: Registration): void { + if (registration.retryTimer === null) { + return; + } + clearTimeout(registration.retryTimer); + registration.retryTimer = null; + } +} diff --git a/crates/agent-mobile/src/lib/chat/stream/streamTypes.ts b/crates/agent-mobile/src/lib/chat/stream/streamTypes.ts new file mode 100644 index 000000000..49c6b7435 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/stream/streamTypes.ts @@ -0,0 +1,265 @@ +import type { ChatEvent } from "@/lib/gatewayTypes"; + +// Wire types for the conversation-scoped chat stream protocol. +// +// One persistent subscription per conversation carries every run: run +// boundaries are events inside the stream (run_started / run_finished), all +// events carry first-class conversation_id / run_id / seq, and reconnects +// resume from the last seen seq. + +export type RunFinishedStatus = "completed" | "failed" | "cancelled"; + +export type RunLifecycleEvent = + | { + type: "run_started"; + conversation_id: string; + run_id: string; + seq: number; + client_request_id?: string; + workdir?: string; + } + | { + type: "run_finished"; + conversation_id: string; + run_id: string; + seq: number; + status: RunFinishedStatus; + error_code?: string; + message?: string; + reason?: string; + title?: string; + client_request_id?: string; + } + | { + type: "run_queued"; + conversation_id: string; + run_id: string; + seq: number; + client_request_id?: string; + } + | { + type: "snapshot"; + conversation_id: string; + run_id: string; + revision?: number; + entries_json?: string; + tool_status?: string | null; + tool_status_is_compaction?: boolean; + // The conversation log seq this snapshot represents through: events + // with seq <= as_of_seq are already folded into entries_json. + as_of_seq?: number; + } + | { + type: "run_content_snapshot"; + conversation_id: string; + run_id: string; + seq: number; + revision: number; + entries_json: string; + content_complete: boolean; + history_required?: boolean; + sha256?: string; + }; + +export type ConversationStreamEvent = (ChatEvent | RunLifecycleEvent) & { + conversation_id?: string; + run_id?: string; + seq?: number; +}; + +export type RunActivityState = "queued" | "running" | "cancelling"; + +export type StreamRunActivity = { + runId: string; + state: RunActivityState; + startedSeq: number; + toolStatus: string | null; + toolStatusIsCompaction: boolean; + clientRequestId?: string; + updatedAt: number; +}; + +export type StreamRunSnapshot = { + runId: string; + revision: number; + entriesJson: string; + toolStatus: string | null; + toolStatusIsCompaction: boolean; + // The log seq the snapshot content covers through (dedup barrier for the + // overlapping event replay). + asOfSeq: number; +}; + +// Parsed chat.subscribe response. +export type ConversationSubscribeResult = { + conversationId: string; + streamEpoch: string; + latestSeq: number; + reset: boolean; + activity: StreamRunActivity | null; + snapshot: StreamRunSnapshot | null; + events: ConversationStreamEvent[]; +}; + +// chat.activity broadcast: the single source for sidebar dots / busy state of +// non-visible conversations. +export type ConversationActivityEvent = { + conversationId: string; + runId: string | null; + running: boolean; + state: RunActivityState | null; + workdir: string | null; + clientRequestId: string | null; + updatedAt: number; +}; + +// chat.command_update push: pre-stream outcomes of a submitted command, +// delivered only to the issuing connection. +export type ChatCommandUpdate = { + runId: string; + clientRequestId: string; + conversationId: string | null; + phase: "bound" | "queued_in_gui" | "failed"; + errorCode: string | null; + message: string | null; +}; + +export type ChatCommandAccepted = { + runId: string; + conversationId: string; + acceptedSeq: number; +}; + +export type ConversationStreamHandlers = { + // A chat.subscribe round-trip completed (initial subscribe, reconnect + // resume, or reset recovery). When reset is true the local tail must be + // rebuilt from snapshot + events. + onSync(result: ConversationSubscribeResult): void; + // A live pushed event. + onEvent(event: ConversationStreamEvent): void; +}; + +function readString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function readNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +export function normalizeRunActivity(raw: unknown): StreamRunActivity | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const runId = readString(value.run_id).trim(); + if (!runId) { + return null; + } + const state = readString(value.state).trim(); + return { + runId, + state: state === "queued" || state === "cancelling" ? state : "running", + startedSeq: readNumber(value.started_seq), + toolStatus: readString(value.tool_status).trim() || null, + toolStatusIsCompaction: value.tool_status_is_compaction === true, + clientRequestId: readString(value.client_request_id).trim() || undefined, + updatedAt: readNumber(value.updated_at), + }; +} + +export function normalizeRunSnapshot(raw: unknown): StreamRunSnapshot | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const runId = readString(value.run_id).trim(); + if (!runId) { + return null; + } + return { + runId, + revision: readNumber(value.revision), + entriesJson: readString(value.entries_json), + toolStatus: readString(value.tool_status).trim() || null, + toolStatusIsCompaction: value.tool_status_is_compaction === true, + asOfSeq: readNumber(value.as_of_seq), + }; +} + +export function normalizeSubscribeResult( + conversationId: string, + raw: unknown, +): ConversationSubscribeResult { + const value = (raw && typeof raw === "object" ? raw : {}) as Record; + const events = Array.isArray(value.events) + ? (value.events.filter( + (event) => event && typeof event === "object", + ) as ConversationStreamEvent[]) + : []; + return { + conversationId: readString(value.conversation_id).trim() || conversationId, + streamEpoch: readString(value.stream_epoch).trim(), + latestSeq: readNumber(value.latest_seq), + reset: value.reset === true, + activity: normalizeRunActivity(value.activity), + snapshot: normalizeRunSnapshot(value.snapshot), + events, + }; +} + +export function normalizeActivityEvent(raw: unknown): ConversationActivityEvent | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const conversationId = readString(value.conversation_id).trim(); + if (!conversationId) { + return null; + } + const state = readString(value.state).trim(); + return { + conversationId, + runId: readString(value.run_id).trim() || null, + running: value.running === true, + state: state === "queued" || state === "running" || state === "cancelling" ? state : null, + workdir: readString(value.workdir).trim() || null, + clientRequestId: readString(value.client_request_id).trim() || null, + updatedAt: readNumber(value.updated_at), + }; +} + +export function normalizeCommandUpdate(raw: unknown): ChatCommandUpdate | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const runId = readString(value.run_id).trim(); + const phase = readString(value.phase).trim(); + if (!runId || (phase !== "bound" && phase !== "queued_in_gui" && phase !== "failed")) { + return null; + } + return { + runId, + clientRequestId: readString(value.client_request_id).trim(), + conversationId: readString(value.conversation_id).trim() || null, + phase, + errorCode: readString(value.error_code).trim() || null, + message: readString(value.message).trim() || null, + }; +} + +export function readEventSeq(event: ConversationStreamEvent): number { + const seq = (event as { seq?: unknown }).seq; + return typeof seq === "number" && Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0; +} + +export function readEventRunId(event: ConversationStreamEvent): string { + const runId = (event as { run_id?: unknown }).run_id; + return typeof runId === "string" ? runId.trim() : ""; +} + +export function readEventConversationId(event: ConversationStreamEvent): string { + const conversationId = (event as { conversation_id?: unknown }).conversation_id; + return typeof conversationId === "string" ? conversationId.trim() : ""; +} diff --git a/crates/agent-mobile/src/lib/chat/stream/useConversationChat.ts b/crates/agent-mobile/src/lib/chat/stream/useConversationChat.ts new file mode 100644 index 000000000..0068571fd --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/stream/useConversationChat.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; +import type { TranscriptSnapshot, TranscriptStore } from "../transcript/transcriptStore"; +import { createTranscriptStore } from "../transcript/transcriptStore"; +import type { ActivityStore } from "./activityStore"; +import type { ConversationStreamEvent, ConversationSubscribeResult } from "./streamTypes"; + +// Registry of transcript stores, one per conversation. Stores persist across +// conversation switches so revisiting a conversation keeps its tail state; +// they are dropped when the conversation is deleted or re-keyed. +export type TranscriptStoreRegistry = { + get(conversationId: string): TranscriptStore; + peek(conversationId: string): TranscriptStore | null; + move(fromConversationId: string, toConversationId: string): void; + remove(conversationId: string): void; + clear(): void; +}; + +export function createTranscriptStoreRegistry(hooks?: { + // A store detected run-topology divergence (see createTranscriptStore's + // onDivergence); reported with the conversation id the store is currently + // registered under so the app can resubscribe that conversation's stream. + onDivergence?: (conversationId: string) => void; +}): TranscriptStoreRegistry { + const stores = new Map(); + // Mutable identity per store: `move` re-keys a draft store to its real + // conversation id, and divergence must report the current key. + const storeIds = new WeakMap(); + return { + get(conversationId) { + let store = stores.get(conversationId); + if (!store) { + const identity = { conversationId }; + store = createTranscriptStore({ + onDivergence: () => hooks?.onDivergence?.(identity.conversationId), + }); + storeIds.set(store, identity); + stores.set(conversationId, store); + } + return store; + }, + peek(conversationId) { + return stores.get(conversationId) ?? null; + }, + move(fromConversationId, toConversationId) { + const store = stores.get(fromConversationId); + if (!store) { + return; + } + stores.delete(fromConversationId); + stores.set(toConversationId, store); + const identity = storeIds.get(store); + if (identity) { + identity.conversationId = toConversationId; + } + }, + remove(conversationId) { + stores.delete(conversationId); + }, + clear() { + stores.clear(); + }, + }; +} + +const EMPTY_TRANSCRIPT: TranscriptSnapshot = { + rows: [], + liveStartIndex: -1, + activeTurnKey: null, + entryCount: 0, + activeRun: null, + toolStatus: null, + toolStatusIsCompaction: false, + retryAttempts: [], + manualCompactionResult: null, + needsHistoryRefresh: false, + foldRevision: 0, + revision: 0, +}; + +export type ConversationChatBinding = { + transcript: TranscriptSnapshot; + // The conversation has an active run (from the transcript's own stream + // state — activityStore covers non-visible conversations). + busy: boolean; +}; + +// Binds the visible conversation to its transcript store and a persistent +// stream subscription. Subscribing eagerly — before any run exists — is what +// makes queue auto-sends race-free: the events just flow in. +export function useConversationChat(params: { + api: GatewayWebSocketClientLike | null; + conversationId: string | null; + registry: TranscriptStoreRegistry; + activityStore: ActivityStore; + isLocalDraft: (conversationId: string) => boolean; + // Extra chances for the app layer to observe stream traffic (titles, + // pending-command settlement, tunnel events, queue refreshes). + onStreamEvent?: (conversationId: string, event: ConversationStreamEvent) => void; + onStreamSync?: (conversationId: string, result: ConversationSubscribeResult) => void; + hasPendingCommand: (conversationId: string) => boolean; + pendingRevision: number; +}): ConversationChatBinding { + const { + api, + conversationId, + registry, + activityStore, + isLocalDraft, + onStreamEvent, + onStreamSync, + hasPendingCommand, + pendingRevision, + } = params; + + const onStreamEventRef = useRef(onStreamEvent); + onStreamEventRef.current = onStreamEvent; + const onStreamSyncRef = useRef(onStreamSync); + onStreamSyncRef.current = onStreamSync; + + const store = useMemo( + () => (conversationId ? registry.get(conversationId) : null), + [conversationId, registry], + ); + + useEffect(() => { + if (!api || !conversationId || !store || isLocalDraft(conversationId)) { + return; + } + const cleanup = api.subscribeConversationStream(conversationId, { + onSync: (result) => { + store.applySync(result); + onStreamSyncRef.current?.(conversationId, result); + }, + onEvent: (event) => { + store.applyEvent(event); + onStreamEventRef.current?.(conversationId, event); + }, + }); + return cleanup; + }, [api, conversationId, store, isLocalDraft]); + + // Streamed deltas commit on a coarse timer while the tab is hidden (rAF is + // frozen there); flushing on refocus paints the accumulated tail in the + // same frame the tab becomes visible instead of a beat later. + useEffect(() => { + if (!store || typeof document === "undefined" || typeof window === "undefined") { + return; + } + const flushIfVisible = () => { + if (document.visibilityState !== "hidden") { + store.flush(); + } + }; + document.addEventListener("visibilitychange", flushIfVisible); + window.addEventListener("pageshow", flushIfVisible); + return () => { + document.removeEventListener("visibilitychange", flushIfVisible); + window.removeEventListener("pageshow", flushIfVisible); + }; + }, [store]); + + const subscribeTranscript = useCallback( + (listener: () => void) => (store ? store.subscribe(listener) : () => {}), + [store], + ); + const getTranscript = useCallback( + () => (store ? store.getSnapshot() : EMPTY_TRANSCRIPT), + [store], + ); + const transcript = useSyncExternalStore(subscribeTranscript, getTranscript, getTranscript); + + const subscribeActivity = useCallback( + (listener: () => void) => activityStore.subscribe(listener), + [activityStore], + ); + const getActivityRevision = useCallback( + () => activityStore.getSnapshot().revision, + [activityStore], + ); + useSyncExternalStore(subscribeActivity, getActivityRevision, getActivityRevision); + + const busy = Boolean( + conversationId && + (transcript.activeRun !== null || + hasPendingCommand(conversationId) || + activityStore.isRunning(conversationId)), + ); + void pendingRevision; + + return { transcript, busy }; +} diff --git a/crates/agent-mobile/src/lib/chat/toolApprovalBridge.ts b/crates/agent-mobile/src/lib/chat/toolApprovalBridge.ts new file mode 100644 index 000000000..a71e56412 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/toolApprovalBridge.ts @@ -0,0 +1,27 @@ +// WebUI 端工具审批桥:审批卡片位于 transcript 深处,提交动作由 GatewayApp 注册 +// (经 gateway chat_queue.tool_approval 送达桌面端审批挂起表)。模块级单例避免 +// 跨多层组件做 props 透传,模式同 askUserQuestionBridge。 +import type { ToolApprovalDecision } from "@liveagent/ui/lib/chat/toolApprovalArgs"; + +export type ToolApprovalSubmitOutcome = { ok: boolean; message?: string }; + +type ToolApprovalDecisionHandler = ( + toolCallId: string, + decision: ToolApprovalDecision, +) => Promise; + +let handler: ToolApprovalDecisionHandler | null = null; + +export function registerToolApprovalDecisionHandler(next: ToolApprovalDecisionHandler | null) { + handler = next; +} + +export function submitToolApprovalDecision( + toolCallId: string, + decision: ToolApprovalDecision, +): Promise { + if (!handler) { + return Promise.resolve({ ok: false, message: "Gateway connection is not ready." }); + } + return handler(toolCallId, decision); +} diff --git a/crates/agent-mobile/src/lib/chat/transcript/historyAlignment.ts b/crates/agent-mobile/src/lib/chat/transcript/historyAlignment.ts new file mode 100644 index 000000000..6968f85b4 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/transcript/historyAlignment.ts @@ -0,0 +1,587 @@ +import { type ChatEntry, safeStringify } from "@/lib/chatUi"; + +import { rebuildTurnFromSnapshot } from "./turnReducer"; +import type { HistoryApplyMode, Turn, UserChatEntry } from "./types"; + +// History ↔ stream reconciliation. The persisted history and the streamed +// turns describe the same conversation, so they are aligned structurally — +// by persisted message refs where available, by end-anchored position +// otherwise — never by content hashing. A logical turn renders from exactly +// one source: while a stream turn covers it, its history twin is +// enrichment-only (messageRef for edit/resend, untrimmed tool payloads) and +// is never emitted as rows. + +export type HistoryTurn = { + user: UserChatEntry | null; + entries: ChatEntry[]; +}; + +export type AlignResult = { + historyEntries: ChatEntry[]; + turns: Turn[]; + changed: boolean; +}; + +export function groupHistoryEntriesIntoTurns(entries: ChatEntry[]): HistoryTurn[] { + const turns: HistoryTurn[] = []; + let current: HistoryTurn | null = null; + for (const entry of entries) { + if (entry.kind === "user") { + if (current) { + turns.push(current); + } + current = { user: entry, entries: [] }; + continue; + } + if (!current) { + // Window cut mid-turn: leading assistant-side content without its user + // message forms a headless turn. + current = { user: null, entries: [] }; + } + current.entries.push(entry); + } + if (current) { + turns.push(current); + } + return turns; +} + +function flattenHistoryTurns(turns: HistoryTurn[]): ChatEntry[] { + const entries: ChatEntry[] = []; + for (const turn of turns) { + if (turn.user) { + entries.push(turn.user); + } + entries.push(...turn.entries); + } + return entries; +} + +function historyTurnHasAssistantContent(turn: HistoryTurn): boolean { + return turn.entries.some((entry) => entry.kind !== "checkpoint"); +} + +function stableUserMessageId(user: UserChatEntry): string { + return user.messageId?.trim() || user.messageRef?.messageId.trim() || ""; +} + +// The desktop assigns the user message id before persistence and mirrors that +// same id through user_message/runtime snapshots. History exposes it through +// messageRef, so matching is exact even when prompts and replies repeat. The +// timestamp/user-only fallbacks cover only identity-less malformed data and +// can never consume a completed historical exchange by content similarity. +function historyTurnMatchesActiveTurn(historyTurn: HistoryTurn, activeTurn: Turn): boolean { + if (!historyTurn.user || !activeTurn.user) { + return false; + } + const historyMessageId = stableUserMessageId(historyTurn.user); + const activeMessageId = stableUserMessageId(activeTurn.user); + if (historyMessageId || activeMessageId) { + return ( + historyMessageId !== "" && activeMessageId !== "" && historyMessageId === activeMessageId + ); + } + + const historyTimestamp = historyTurn.user.timestamp; + const activeTimestamp = activeTurn.user.timestamp; + if ( + typeof historyTimestamp === "number" && + typeof activeTimestamp === "number" && + historyTimestamp === activeTimestamp + ) { + return true; + } + + if (!historyTurnHasAssistantContent(historyTurn)) { + return historyTurn.user.text.trim() === activeTurn.user.text.trim(); + } + return false; +} + +function isLocalTurn(turn: Turn): boolean { + return turn.key.startsWith("local:"); +} + +function isActiveTurn(turn: Turn): boolean { + return turn.phase === "pending" || turn.phase === "streaming"; +} + +function enrichUserSlot(user: UserChatEntry, historyUser: UserChatEntry): UserChatEntry { + let next = user; + if (historyUser.messageRef && user.messageRef?.messageId !== historyUser.messageRef.messageId) { + next = { ...next, messageRef: historyUser.messageRef }; + } + if (next.attachments.length === 0 && historyUser.attachments.length > 0) { + next = { ...next, attachments: historyUser.attachments }; + } + return next; +} + +// Upgrades a stream turn's payloads with their persisted twins (the live +// path trims large arguments/results; history carries the full content). +// Matching is by tool-call identity, falling back to same-kind ordinal — +// never by content. Streamed assistant/thinking text is already complete and +// is never replaced; but a turn that lost its content, or kept possibly +// incomplete content across a reset whose replay could not cover the run +// (contentStale), adopts the persisted entries wholesale — that is the only +// content history is authoritative for. +function enrichTurnFromHistory(turn: Turn, historyTurn: HistoryTurn): Turn { + let next = turn; + + if (turn.user && historyTurn.user) { + const nextUser = enrichUserSlot(turn.user, historyTurn.user); + if (nextUser !== turn.user) { + next = { ...next, user: nextUser }; + } + } + + if ( + (next.entries.length === 0 || next.contentStale === true) && + historyTurn.entries.length > 0 && + next.phase === "settled" + ) { + const rebuilt = rebuildTurnFromSnapshot(next, historyTurn.entries); + return { + ...rebuilt, + contentStale: false, + contentConfirmed: true, + inferredLossErrorEntryId: undefined, + }; + } + + const historyToolCalls = historyTurn.entries.filter( + (entry): entry is Extract => entry.kind === "tool_call", + ); + const historyToolResults = historyTurn.entries.filter( + (entry): entry is Extract => entry.kind === "tool_result", + ); + if (historyToolCalls.length === 0 && historyToolResults.length === 0) { + return next; + } + + let entries: ChatEntry[] | null = null; + let toolCallOrdinal = 0; + let toolResultOrdinal = 0; + for (let index = 0; index < next.entries.length; index += 1) { + const entry = next.entries[index]; + if (!entry) continue; + + if (entry.kind === "tool_call") { + const ordinal = toolCallOrdinal++; + const twin = + historyToolCalls.find( + (candidate) => + entry.toolCall.id.trim() !== "" && candidate.toolCall.id === entry.toolCall.id, + ) ?? historyToolCalls[ordinal]; + if ( + twin && + (safeStringify(twin.toolCall) !== safeStringify(entry.toolCall) || + twin.summary !== entry.summary) + ) { + if (!entries) entries = next.entries.slice(); + entries[index] = { ...twin, id: entry.id, round: entry.round }; + } + continue; + } + + if (entry.kind === "tool_result") { + const ordinal = toolResultOrdinal++; + const twin = + historyToolResults.find( + (candidate) => + entry.toolResult.toolCallId.trim() !== "" && + candidate.toolResult.toolCallId === entry.toolResult.toolCallId, + ) ?? historyToolResults[ordinal]; + if (twin && safeStringify(twin.toolResult) !== safeStringify(entry.toolResult)) { + if (!entries) entries = next.entries.slice(); + entries[index] = { ...twin, id: entry.id, round: entry.round }; + } + } + } + if (entries) { + next = next === turn ? { ...turn, entries } : { ...next, entries }; + } + return next; +} + +// Persistence-lag protection. The desktop reports run_finished before its +// final history flush lands (the post-run persist is fire-and-forget), so a +// window fetched in that gap carries the exchange's user message WITHOUT its +// reply. Such a user-only twin must never count as covering a settled turn +// that holds streamed assistant content: the turn keeps rendering (it holds +// the only copy of the reply), its echo is trimmed from the window so the +// prompt renders once, and the twin's messageRef is adopted so the next +// full window ref-matches and takes over normally. +// +// Matching is ref-anchored where the turn carries a persisted ref; ref-less +// turns pair end-anchored against trailing user-only window turns, guarded +// by prompt-text equality so a foreign client's just-persisted prompt is +// never mistaken for the settled turn's own echo. +function protectLaggedSettledTurns( + historyTurns: HistoryTurn[], + turns: Turn[], +): { historyTurns: HistoryTurn[]; protectedTurns: Map } { + const protectedTurns = new Map(); + const settledWithContent = turns.filter( + (turn) => + !isActiveTurn(turn) && !isLocalTurn(turn) && turn.user !== null && turn.entries.length > 0, + ); + if (settledWithContent.length === 0) { + return { historyTurns, protectedTurns }; + } + + const adoptTwinUser = (turn: Turn, twin: HistoryTurn): Turn => { + if (!turn.user || !twin.user) { + return turn; + } + const user = enrichUserSlot(turn.user, twin.user); + return user === turn.user ? turn : { ...turn, user }; + }; + + let remaining = historyTurns; + + // Ref-anchored: the persisted user message is in the window but carries no + // assistant content yet. + for (const turn of settledWithContent) { + const ref = turn.user?.messageRef?.messageId; + if (!ref) continue; + const twinIndex = remaining.findIndex( + (candidate) => candidate.user?.messageRef?.messageId === ref, + ); + if (twinIndex < 0) continue; + const twin = remaining[twinIndex]; + if (!twin || historyTurnHasAssistantContent(twin)) continue; + protectedTurns.set(turn, adoptTwinUser(turn, twin)); + remaining = [...remaining.slice(0, twinIndex), ...remaining.slice(twinIndex + 1)]; + } + + // Positional: trailing user-only window turns are the persist-lagged + // echoes of the freshest exchanges; pair them end-anchored with ref-less + // settled turns holding content, gated by identical prompt text. A + // trailing echo with no matching turn (e.g. a queued next prompt whose + // persist overtook the lagging reply flush) is skipped, not a pairing + // stop — the reply's turn may sit right behind it. + const reflessSettled = settledWithContent.filter( + (turn) => !turn.user?.messageRef && !protectedTurns.has(turn), + ); + let trailingUserOnly = 0; + while (trailingUserOnly < remaining.length) { + const candidate = remaining[remaining.length - 1 - trailingUserOnly]; + if (!candidate?.user || historyTurnHasAssistantContent(candidate)) { + break; + } + trailingUserOnly += 1; + } + const matchedTwinIndexes = new Set(); + let turnCursor = reflessSettled.length - 1; + for (let offset = 0; offset < trailingUserOnly && turnCursor >= 0; offset += 1) { + const twinIndex = remaining.length - 1 - offset; + const twin = remaining[twinIndex]; + const turn = reflessSettled[turnCursor]; + if (!twin?.user || !turn?.user) { + continue; + } + if (twin.user.text.trim() !== turn.user.text.trim()) { + continue; + } + protectedTurns.set(turn, adoptTwinUser(turn, twin)); + matchedTwinIndexes.add(twinIndex); + turnCursor -= 1; + } + if (matchedTwinIndexes.size > 0) { + remaining = remaining.filter((_, index) => !matchedTwinIndexes.has(index)); + } + + return { historyTurns: remaining, protectedTurns }; +} + +// The guarded full-repaint fallback: history becomes authoritative for +// everything it covers. Turns whose content history cannot know yet are +// kept — a persisted-ref lookup misses (persistence lag), a user-only twin +// whose reply flush is still in flight (see protectLaggedSettledTurns), or a +// local error pseudo-turn that was never on the server. Worst case is a +// repaint, never a duplicate and never on-screen content loss. +function replaceAll(entries: ChatEntry[], turns: Turn[], historyTurns: HistoryTurn[]): AlignResult { + const protection = protectLaggedSettledTurns(historyTurns, turns); + const remainingHistoryTurns = protection.historyTurns; + const protectedTurns = protection.protectedTurns; + const knownRefs = new Set( + remainingHistoryTurns.flatMap((turn) => + turn.user?.messageRef?.messageId ? [turn.user.messageRef.messageId] : [], + ), + ); + const kept = turns.flatMap((turn) => { + const protectedTurn = protectedTurns.get(turn); + if (protectedTurn) { + return [protectedTurn]; + } + if (isLocalTurn(turn) || isActiveTurn(turn)) { + return [turn]; + } + const ref = turn.user?.messageRef?.messageId; + return ref !== undefined && !knownRefs.has(ref) ? [turn] : []; + }); + return { + historyEntries: protectedTurns.size > 0 ? flattenHistoryTurns(remainingHistoryTurns) : entries, + turns: kept, + changed: true, + }; +} + +// mode "replace" — a full (re)load of the conversation (switch-in, +// load-more). The parsed history becomes the folded region; settled turns +// covered by it are dropped in its favor (deterministic parse ids keep the +// previously-loaded region id-stable), turns history cannot know yet +// survive, and pending/streaming turns — the active exchange — always +// survive with their persisted echoes trimmed so the prompt renders once. +// "Covered" requires the reply, not just the prompt: a settled turn whose +// window twin is still user-only (the desktop's post-run flush races the +// fetch) keeps rendering its streamed content (protectLaggedSettledTurns). +function alignReplace(params: { + turns: Turn[]; + entries: ChatEntry[]; + rebaseReconcile?: boolean; +}): AlignResult { + let historyTurns = groupHistoryEntriesIntoTurns(params.entries); + + // Trim persisted echoes of active exchanges. Ref-anchored first (covers + // edit-resends whose turn already carries the base ref)… + const activeWithUser = params.turns.filter((turn) => isActiveTurn(turn) && turn.user !== null); + const activeRefs = new Set( + activeWithUser.flatMap((turn) => + turn.user?.messageRef?.messageId ? [turn.user.messageRef.messageId] : [], + ), + ); + if (activeRefs.size > 0) { + historyTurns = historyTurns.filter( + (turn) => + !turn.user?.messageRef?.messageId || !activeRefs.has(turn.user.messageRef.messageId), + ); + } + + // …then pair the remaining ref-less active prompts with the trailing + // persisted turns. History may already contain a partial assistant reply: + // opening a running conversation races history.get against chat.subscribe, + // so limiting this match to user-only turns leaves two copies when history + // wins that race. Stable message ids disambiguate repeated prompts and + // repeated replies; the live turn remains authoritative only after proof. + const reflessActive = activeWithUser.filter((turn) => !turn.user?.messageRef); + const enrichedActive = new Map(); + const matchedHistoryIndexes = new Set(); + let historyCursor = historyTurns.length - 1; + for (let activeCursor = reflessActive.length - 1; activeCursor >= 0; activeCursor -= 1) { + const historyTurn = historyTurns[historyCursor]; + const keptTurn = reflessActive[activeCursor]; + const historyUser = historyTurn?.user; + const keptUser = keptTurn?.user; + if ( + !historyTurn || + !keptTurn || + !historyUser || + !keptUser || + !historyTurnMatchesActiveTurn(historyTurn, keptTurn) + ) { + break; + } + const enrichedUser = enrichUserSlot(keptUser, historyUser); + if (enrichedUser !== keptUser) { + enrichedActive.set(keptTurn, { ...keptTurn, user: enrichedUser }); + } + matchedHistoryIndexes.add(historyCursor); + historyCursor -= 1; + } + if (matchedHistoryIndexes.size > 0) { + historyTurns = historyTurns.filter((_, index) => !matchedHistoryIndexes.has(index)); + } + + // Settled turns whose persisted echo reached the window without its reply + // (post-run flush still in flight) survive with their echo trimmed. + const protection = protectLaggedSettledTurns(historyTurns, params.turns); + historyTurns = protection.historyTurns; + const protectedTurns = protection.protectedTurns; + + // Decide which settled turns the fetched window covers. Ref-anchored turns + // are covered exactly when their persisted message is in the window. The + // window is always a suffix of the conversation, so for ref-less settled + // turns — the freshest exchanges, whose persistence is the most likely to + // lag the fetch — the number of window turns left after ref matching says + // how many of them are covered; the newest excess survives, or the last + // reply would blank on a reload racing persistence. + const knownRefs = new Set( + historyTurns.flatMap((turn) => + turn.user?.messageRef?.messageId ? [turn.user.messageRef.messageId] : [], + ), + ); + const settledTurns = params.turns.filter( + (turn) => !isActiveTurn(turn) && !isLocalTurn(turn) && !protectedTurns.has(turn), + ); + const refMatchedCount = settledTurns.filter((turn) => { + const ref = turn.user?.messageRef?.messageId; + return ref !== undefined && knownRefs.has(ref); + }).length; + const reflessSettled = settledTurns.filter((turn) => !turn.user?.messageRef); + const historyUserCount = historyTurns.filter((turn) => turn.user !== null).length; + const coveredRefless = Math.min( + reflessSettled.length, + Math.max(0, historyUserCount - refMatchedCount), + ); + // Rebase reconciliation: the server authoritatively deleted a settled + // suffix (edit-resend truncation whose anchor this client missed), so + // uncovered ref-less settled turns are stale edit versions, not + // persistence-lagged exchanges — none survive. Genuinely lagged replies + // were already captured by protectLaggedSettledTurns above. + const keptRefless = new Set( + params.rebaseReconcile === true ? [] : reflessSettled.slice(coveredRefless), + ); + + const turns = params.turns.flatMap((turn) => { + const protectedTurn = protectedTurns.get(turn); + if (protectedTurn) { + return [protectedTurn]; + } + if (isActiveTurn(turn)) { + return [enrichedActive.get(turn) ?? turn]; + } + if (isLocalTurn(turn)) { + return [turn]; + } + const ref = turn.user?.messageRef?.messageId; + if (ref !== undefined) { + return knownRefs.has(ref) ? [] : [turn]; + } + return keptRefless.has(turn) ? [turn] : []; + }); + + return { + historyEntries: flattenHistoryTurns(historyTurns), + turns, + changed: true, + }; +} + +// mode "enrich" — the quiet idle refresh. At idle the rendered conversation +// is exactly historyEntries + turns, so the incoming window's user-turn +// count discriminates every case: +// +// |V| < |S| partial window too small to say anything — skip +// |V| < region + |S| suffix window: pair the last |S| turns, leave the +// existing history region untouched +// |V| == region + |S| full window: pair the last |S| turns, everything +// older becomes the history region +// |V| > region + |S| history knows exchanges this client never streamed +// — guarded full repaint (persist-lagged and local +// turns survive; see replaceAll) +// +// Paired turns are upgraded in place (messageRef, full tool payloads); the +// pairing is positional, end-anchored, guarded by persisted message ids +// where both sides carry one. +function alignEnrich(params: { + historyEntries: ChatEntry[]; + turns: Turn[]; + entries: ChatEntry[]; +}): AlignResult { + const unchanged: AlignResult = { + historyEntries: params.historyEntries, + turns: params.turns, + changed: false, + }; + + // Never merge under an active exchange (callers gate on idle; keep the + // invariant local too). + if (params.turns.some(isActiveTurn)) { + return unchanged; + } + + const historyTurns = groupHistoryEntriesIntoTurns(params.entries); + const historyWithUser = historyTurns.filter((turn) => turn.user !== null); + const storeWithUser = params.turns.filter((turn) => turn.user !== null); + const regionUserCount = params.historyEntries.reduce( + (count, entry) => (entry.kind === "user" ? count + 1 : count), + 0, + ); + + if (historyWithUser.length < storeWithUser.length) { + return unchanged; + } + if (historyWithUser.length > regionUserCount + storeWithUser.length) { + return replaceAll(params.entries, params.turns, historyTurns); + } + + // messageRef guard: conflicting persisted identities mean the store turns + // are stale — repaint rather than mis-enrich. + for (let pair = 0; pair < storeWithUser.length; pair += 1) { + const storeTurn = storeWithUser[storeWithUser.length - 1 - pair]; + const historyTurn = historyWithUser[historyWithUser.length - 1 - pair]; + const storeRef = storeTurn?.user?.messageRef?.messageId; + const historyRef = historyTurn?.user?.messageRef?.messageId; + if (storeRef && historyRef && storeRef !== historyRef) { + return replaceAll(params.entries, params.turns, historyTurns); + } + } + + let turns = params.turns; + for (let pair = 0; pair < storeWithUser.length; pair += 1) { + const storeTurn = storeWithUser[storeWithUser.length - 1 - pair]; + const historyTurn = historyWithUser[historyWithUser.length - 1 - pair]; + if (!storeTurn || !historyTurn) continue; + const enriched = enrichTurnFromHistory(storeTurn, historyTurn); + if (enriched !== storeTurn) { + if (turns === params.turns) turns = params.turns.slice(); + const index = turns.indexOf(storeTurn); + if (index >= 0) { + turns[index] = enriched; + } + } + } + + // A suffix window says nothing about older rows: keep the region as-is. + if (historyWithUser.length < regionUserCount + storeWithUser.length) { + if (turns === params.turns) { + return unchanged; + } + return { historyEntries: params.historyEntries, turns, changed: true }; + } + + // Full window: everything older than the paired turns renders from + // history. Parse ids are deterministic, so an id-level comparison detects + // every structural change; identical content reparses to identical ids. + const pairStart = + storeWithUser.length === 0 + ? historyTurns.length + : historyTurns.indexOf(historyWithUser[historyWithUser.length - storeWithUser.length]!); + const nextHistoryEntries = flattenHistoryTurns(historyTurns.slice(0, pairStart)); + + const historyChanged = + nextHistoryEntries.length !== params.historyEntries.length || + nextHistoryEntries.some((entry, index) => params.historyEntries[index]?.id !== entry.id); + + if (!historyChanged && turns === params.turns) { + return unchanged; + } + return { + historyEntries: historyChanged ? nextHistoryEntries : params.historyEntries, + turns, + changed: true, + }; +} + +export function alignHistory(params: { + historyEntries: ChatEntry[]; + turns: Turn[]; + entries: ChatEntry[]; + mode: HistoryApplyMode; + // Set after a rebased event whose truncation anchor was missing locally: + // the server deleted a settled suffix this client still renders, so the + // replace must not keep uncovered ref-less settled turns (only meaningful + // with mode "replace"). + rebaseReconcile?: boolean; +}): AlignResult { + if (params.mode === "replace") { + return alignReplace({ + turns: params.turns, + entries: params.entries, + rebaseReconcile: params.rebaseReconcile, + }); + } + return alignEnrich(params); +} diff --git a/crates/agent-mobile/src/lib/chat/transcript/rows.ts b/crates/agent-mobile/src/lib/chat/transcript/rows.ts new file mode 100644 index 000000000..5d0c6d209 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/transcript/rows.ts @@ -0,0 +1,427 @@ +import type { SubagentBatchDetails } from "@liveagent/ui/lib/subagents/protocol"; +import type { ToolCall, ToolResultMessage } from "@/lib/agentTypes"; +import { + appendTextDeltaToRound, + appendThinkingDeltaToRound, + attachToolResultToRound, + buildSubagentPlaceholderToolCalls, + getRoundToolTrace, + hasRoundContent, + upsertHostedSearchToRound, + upsertToolCallToRound, +} from "@/lib/chat/uiMessages"; +import { + type AssistantMeta, + type ChatEntry, + type GatewayTranscriptRound, + hashValue, + stripRecoveredToolCallMarkup, +} from "@/lib/chatUi"; + +import type { TranscriptRow, TranscriptRowOrigin, Turn } from "./types"; + +// The one place transcript rows are built. Consecutive assistant-side +// entries fold into a single assistant row of rounds; rounds without any +// renderable content are dropped, and an assistant row is only emitted when +// at least one round survives — an avatar can therefore never render without +// content next to it. + +type AssistantGroupBuilder = { + id: string; + rounds: GatewayTranscriptRound[]; + roundIndexByNumber: Map; + timestamp?: number; +}; + +function createTranscriptRound(groupId: string, round: number): GatewayTranscriptRound { + return { + key: `${groupId}:r${round}`, + round, + blocks: [], + runningToolCallIds: [], + }; +} + +function ensureAssistantGroup( + builder: AssistantGroupBuilder | null, + seedEntryId: string, +): AssistantGroupBuilder { + if (builder) return builder; + return { + id: `ag:${seedEntryId}`, + rounds: [], + roundIndexByNumber: new Map(), + }; +} + +function ensureTranscriptRound( + builder: AssistantGroupBuilder, + requestedRound?: number, +): GatewayTranscriptRound { + const roundNumber = requestedRound ?? builder.rounds[builder.rounds.length - 1]?.round ?? 1; + const existingIndex = builder.roundIndexByNumber.get(roundNumber); + if (existingIndex !== undefined) { + return builder.rounds[existingIndex]; + } + + const nextRound = createTranscriptRound(builder.id, roundNumber); + builder.roundIndexByNumber.set(roundNumber, builder.rounds.length); + builder.rounds.push(nextRound); + return nextRound; +} + +function updateTranscriptRound( + builder: AssistantGroupBuilder, + roundNumber: number, + updater: (round: GatewayTranscriptRound) => GatewayTranscriptRound, +) { + const round = ensureTranscriptRound(builder, roundNumber); + const index = builder.roundIndexByNumber.get(round.round) ?? 0; + builder.rounds[index] = updater(round); +} + +function collapseThinking(round: GatewayTranscriptRound): GatewayTranscriptRound { + if (!round.thinkingOpen) return round; + return { ...round, thinkingOpen: false }; +} + +function mergeAssistantMeta( + current: AssistantMeta | undefined, + next: AssistantMeta | undefined, +): AssistantMeta | undefined { + if (!next) return current; + return { ...(current ?? {}), ...next }; +} + +function findToolCallInRound(round: GatewayTranscriptRound, toolCallId: string) { + return getRoundToolTrace(round).find((item) => item.toolCall.id === toolCallId)?.toolCall; +} + +function findPendingToolCallByName(round: GatewayTranscriptRound, name: string) { + const trace = getRoundToolTrace(round); + for (let index = trace.length - 1; index >= 0; index -= 1) { + const item = trace[index]; + if (!item) continue; + if (item.toolCall.name === name && !item.toolResult) { + return item.toolCall; + } + } + return undefined; +} + +function resolveToolCallForResult( + builder: AssistantGroupBuilder, + roundNumber: number, + toolResult: ToolResultMessage, +): ToolCall { + const requestedRound = ensureTranscriptRound(builder, roundNumber); + const byId = toolResult.toolCallId && findToolCallInRound(requestedRound, toolResult.toolCallId); + if (byId) { + return byId; + } + + const byName = + toolResult.toolName && findPendingToolCallByName(requestedRound, toolResult.toolName); + if (byName) { + return byName; + } + + for (let index = builder.rounds.length - 1; index >= 0; index -= 1) { + const round = builder.rounds[index]; + if (!round) continue; + const candidateById = + toolResult.toolCallId && findToolCallInRound(round, toolResult.toolCallId); + if (candidateById) { + return candidateById; + } + const candidateByName = + toolResult.toolName && findPendingToolCallByName(round, toolResult.toolName); + if (candidateByName) { + return candidateByName; + } + } + + return { + type: "toolCall", + id: toolResult.toolCallId || `orphan:${hashValue([toolResult.toolName, toolResult.content])}`, + name: toolResult.toolName || "Tool", + arguments: {}, + } as ToolCall; +} + +function asSubagentBatchDetails(details: unknown): SubagentBatchDetails | null { + const record = details && typeof details === "object" ? (details as Record) : {}; + return record.kind === "subagent_batch" && Array.isArray(record.agents) + ? (record as unknown as SubagentBatchDetails) + : null; +} + +// The batch result is addressed to the parent Agent call, which is never +// stored as a block (it is expanded into per-agent cards), so id/name lookups +// in resolveToolCallForResult would mis-bind it to a pending card. Synthesize +// the parent call instead; attachToolResultToRound expands an ok batch into +// cards and keeps a rejected batch visible as an error block. +function buildParentAgentToolCallForBatchResult(toolResult: ToolResultMessage): ToolCall { + return { + type: "toolCall", + id: toolResult.toolCallId || `orphan:${hashValue([toolResult.toolName, toolResult.content])}`, + name: toolResult.toolName || "Agent", + arguments: {}, + } as ToolCall; +} + +export function buildRowsFromEntries( + entries: ChatEntry[], + origin: TranscriptRowOrigin, +): TranscriptRow[] { + const rows: TranscriptRow[] = []; + let assistantGroup: AssistantGroupBuilder | null = null; + + const flushAssistantGroup = () => { + if (!assistantGroup) { + return; + } + // The content gate: rounds that never produced anything renderable + // (meta-only token carriers, empty leading flushes) are dropped, and a + // group whose every round dropped emits no row at all. + const rounds = assistantGroup.rounds.filter((round) => hasRoundContent(round)); + if (rounds.length > 0) { + rows.push({ + key: assistantGroup.id, + origin, + kind: "assistant", + rounds, + timestamp: assistantGroup.timestamp, + }); + } + assistantGroup = null; + }; + + for (const entry of entries) { + if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { + flushAssistantGroup(); + if (entry.kind === "user") { + rows.push({ + key: entry.id, + origin, + kind: "user", + text: entry.text, + attachments: entry.attachments, + messageRef: entry.messageRef, + timestamp: entry.timestamp, + }); + } else if (entry.kind === "checkpoint") { + rows.push({ + key: entry.id, + origin, + kind: "checkpoint", + content: entry.content, + summaryId: entry.summaryId, + coveredMessageCount: entry.coveredMessageCount, + generatedBy: entry.generatedBy, + contextUsageTokens: entry.contextUsageTokens, + timestamp: entry.timestamp, + }); + } else { + rows.push({ key: entry.id, origin, kind: "error", text: entry.text }); + } + continue; + } + + assistantGroup = ensureAssistantGroup(assistantGroup, entry.id); + const roundNumber = + entry.round ?? assistantGroup.rounds[assistantGroup.rounds.length - 1]?.round ?? 1; + + if (entry.kind === "assistant") { + if (entry.timestamp) { + assistantGroup.timestamp = entry.timestamp; + } + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + let nextRound = round; + if (entry.text !== "") { + nextRound = appendTextDeltaToRound( + collapseThinking(nextRound), + entry.text, + ) as GatewayTranscriptRound; + } + return { + ...nextRound, + meta: mergeAssistantMeta(nextRound.meta, entry.meta), + }; + }); + continue; + } + + if (entry.kind === "thinking") { + const sanitizedThinking = stripRecoveredToolCallMarkup(entry.text); + if (sanitizedThinking === "") { + continue; + } + updateTranscriptRound(assistantGroup, roundNumber, (round) => ({ + ...(appendThinkingDeltaToRound(round, sanitizedThinking) as GatewayTranscriptRound), + thinkingOpen: true, + })); + continue; + } + + if (entry.kind === "tool_call") { + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + const visibleToolCalls = buildSubagentPlaceholderToolCalls(entry.toolCall); + const runningCandidateIds = + visibleToolCalls.length > 0 + ? visibleToolCalls.map((toolCall) => toolCall.id) + : entry.toolCall.id + ? [entry.toolCall.id] + : []; + const withToolCall = upsertToolCallToRound( + collapseThinking(round), + entry.toolCall, + ) as GatewayTranscriptRound; + const visibleToolCallIds = new Set( + getRoundToolTrace(withToolCall) + .map((item) => item.toolCall.id) + .filter((id): id is string => Boolean(id)), + ); + const runningToolCallIds = runningCandidateIds.reduce( + (ids, id) => (visibleToolCallIds.has(id) && !ids.includes(id) ? [...ids, id] : ids), + withToolCall.runningToolCallIds, + ); + return { ...withToolCall, runningToolCallIds }; + }); + continue; + } + + if (entry.kind === "hosted_search") { + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + const nextRound = upsertHostedSearchToRound( + collapseThinking(round), + entry.hostedSearch, + ) as GatewayTranscriptRound; + const visibleToolCallIds = new Set( + getRoundToolTrace(nextRound) + .map((item) => item.toolCall.id) + .filter((id): id is string => Boolean(id)), + ); + return { + ...nextRound, + runningToolCallIds: nextRound.runningToolCallIds.filter((id) => + visibleToolCallIds.has(id), + ), + }; + }); + continue; + } + + if (entry.kind === "tool_result") { + const batchDetails = asSubagentBatchDetails(entry.toolResult.details); + const toolCall = batchDetails + ? buildParentAgentToolCallForBatchResult(entry.toolResult) + : resolveToolCallForResult(assistantGroup, roundNumber, entry.toolResult); + // A batch result settles the parent and every derived card id (ok + // batches upgrade the cards in place; rejected batches drop them). + const settledCardIdPrefix = batchDetails ? `${toolCall.id}:agent:` : null; + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + const withResult = attachToolResultToRound( + collapseThinking(round), + toolCall, + entry.toolResult, + ) as GatewayTranscriptRound; + return { + ...withResult, + runningToolCallIds: withResult.runningToolCallIds.filter( + (id) => + id !== toolCall.id && !(settledCardIdPrefix && id.startsWith(settledCardIdPrefix)), + ), + }; + }); + } + } + + flushAssistantGroup(); + return rows; +} + +const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = []; + +function normalizeSettledRound(round: GatewayTranscriptRound): GatewayTranscriptRound { + if (round.runningToolCallIds.length === 0 && round.thinkingOpen === undefined) { + return round; + } + return { ...round, runningToolCallIds: EMPTY_RUNNING_TOOL_CALL_IDS, thinkingOpen: undefined }; +} + +// Live-only round state (running spinners, auto-opened thinking) is cleared +// once at row build time — rows are cached per turn/history-entries identity, +// so renderers receive stable, already-normalized round objects instead of +// cloning per render. +export function normalizeSettledRowRounds(rows: TranscriptRow[]): TranscriptRow[] { + return rows.map((row) => + row.kind === "assistant" ? { ...row, rounds: row.rounds.map(normalizeSettledRound) } : row, + ); +} + +// A turn's rows: the user bubble first, then its run's assistant content — +// derived from one object, so the order is fixed by construction. +export function buildTurnRows(turn: Turn): TranscriptRow[] { + const rows: TranscriptRow[] = []; + if (turn.user) { + rows.push({ + key: turn.user.id, + origin: "stream", + kind: "user", + text: turn.user.text, + attachments: turn.user.attachments, + messageRef: turn.user.messageRef, + timestamp: turn.user.timestamp, + }); + } + const settled = turn.phase === "settled"; + for (const row of buildRowsFromEntries(turn.entries, "stream")) { + const withTurnKey = row.kind === "assistant" ? { ...row, turnKey: turn.key } : row; + rows.push( + settled && withTurnKey.kind === "assistant" + ? { ...withTurnKey, rounds: withTurnKey.rounds.map(normalizeSettledRound) } + : withTurnKey, + ); + } + return rows; +} + +// Row keys feed React reconciliation and the virtualizer's measurement cache; +// a duplicate key collapses row positions onto each other. Ids are unique by +// construction, but this single canonical pass makes the guarantee local to +// the builder instead of distributed across every producer. Pass `seen` to +// dedupe one region against keys already taken by another. +// +// Checkpoint rows are the exception to rename-on-collision: their id is a +// content identity (`checkpoint-`), so a colliding checkpoint is +// the SAME logical card seen from two sources — the history region and the +// (user-less) manual-compaction turn that streamed it. Renaming would render +// the card twice; the later copy is dropped instead (region order puts the +// history copy first, and the shared key keeps React/measurement identity +// stable when the rendering source flips). +export function dedupeRowKeys(rows: TranscriptRow[], seen = new Set()): TranscriptRow[] { + let next: TranscriptRow[] | null = null; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (!row) continue; + if (seen.has(row.key)) { + if (row.kind === "checkpoint") { + next ??= rows.slice(0, index); + continue; + } + let suffix = 2; + while (seen.has(`${row.key}#${suffix}`)) { + suffix += 1; + } + const key = `${row.key}#${suffix}`; + next ??= rows.slice(0, index); + next.push({ ...row, key }); + seen.add(key); + continue; + } + seen.add(row.key); + next?.push(row); + } + return next ?? rows; +} diff --git a/crates/agent-mobile/src/lib/chat/transcript/transcriptStore.ts b/crates/agent-mobile/src/lib/chat/transcript/transcriptStore.ts new file mode 100644 index 000000000..9aa42ecb6 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/transcript/transcriptStore.ts @@ -0,0 +1,1409 @@ +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import type { + ConversationStreamEvent, + ConversationSubscribeResult, + StreamRunActivity, +} from "@/lib/chat/stream/streamTypes"; +import { readEventRunId, readEventSeq } from "@/lib/chat/stream/streamTypes"; +import { type ChatEntry, normalizeLiveUploadedFiles, readHistoryMessageRef } from "@/lib/chatUi"; +import type { ChatEvent } from "@/lib/gatewayTypes"; + +import { alignHistory } from "./historyAlignment"; +import { + buildRowsFromEntries, + buildTurnRows, + dedupeRowKeys, + normalizeSettledRowRounds, +} from "./rows"; +import { + applyEventToTurn, + createTurn, + optimisticUserEntryId, + rebuildTurnFromSnapshot, + seededUserEntryId, +} from "./turnReducer"; +import type { + HistoryApplyMode, + ManualCompactionResult, + RetryAttemptRecord, + TranscriptRow, + TranscriptSnapshot, + Turn, + UserChatEntry, +} from "./types"; + +// One transcript store per conversation, built on two structures: +// +// historyEntries — the parsed persisted conversation (deterministic ids), +// always rendered first +// turns — live exchanges, one Turn per run: a single user-bubble +// slot plus the run's assistant entries +// +// The snapshot is a single derived row list plus liveStartIndex, rendered by +// one virtualized container. Because every row comes from one list built +// from one source, a prompt or reply can never render twice, and a turn's +// user bubble always precedes its assistant content. Completion is +// supersession: at the next run_started, settled turns just flip `folded`, +// which moves rows from the live suffix into the identity-cached prefix — +// row keys, row objects and the DOM container are all unchanged, so the +// fold is a pure data transition and nothing remounts. + +export type { RetryAttemptRecord, TranscriptRow, TranscriptSnapshot, Turn } from "./types"; + +export type TranscriptStore = { + getSnapshot(): TranscriptSnapshot; + subscribe(listener: () => void): () => void; + // Stream plumbing. + applySync(result: ConversationSubscribeResult): void; + applyEvent(event: ConversationStreamEvent): void; + // Optimistic local echo for a command this client is submitting. The + // seeded user_message binds the turn by client_request_id — the bubble + // keeps its id and never remounts. + addOptimisticUserEntry(params: { + clientRequestId: string; + text: string; + attachments?: UserChatEntry["attachments"]; + // For edit-resend, truncate the visible transcript before inserting the + // optimistic bubble so it appears at the edited turn immediately. The + // later stream `rebased` event is an idempotent confirmation. + baseMessageRef?: HistoryMessageRef; + }): void; + removeOptimisticUserEntry(clientRequestId: string): void; + // edit_resend failure/parked compensation: restore the pre-truncation + // transcript stashed by addOptimisticUserEntry. Returns false once + // authoritative data (event/sync/snapshot) invalidated the stash. Optional + // so lightweight test doubles need not implement it. + restoreEditResendTranscript?(clientRequestId: string): boolean; + // Failure surfaced outside the stream (command never bound). + appendLocalError(message: string): void; + // History application: "replace" for full (re)loads, "enrich" for the + // idle quiet refresh (messageRef / full tool payload upgrades). + applyHistorySnapshot(entries: ChatEntry[], options?: { mode?: HistoryApplyMode }): void; + // Fold the settled turns outside of run_started (conversation switched + // away; keeps the next mount clean). + foldSettledTurns(): void; + flush(): void; +}; + +const EMPTY_RETRY_ATTEMPTS: readonly RetryAttemptRecord[] = []; + +const EMPTY_SNAPSHOT: TranscriptSnapshot = { + rows: [], + liveStartIndex: -1, + activeTurnKey: null, + entryCount: 0, + activeRun: null, + toolStatus: null, + toolStatusIsCompaction: false, + retryAttempts: EMPTY_RETRY_ATTEMPTS, + manualCompactionResult: null, + needsHistoryRefresh: false, + foldRevision: 0, + revision: 0, +}; + +// tool_status events without a retryAttempts array leave the current list +// untouched (null result); an array — including an empty one — replaces it. +function normalizeRetryAttempts(raw: unknown): RetryAttemptRecord[] | null { + if (!Array.isArray(raw)) { + return null; + } + const attempts: RetryAttemptRecord[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== "object") { + continue; + } + const value = entry as Record; + const attempt = typeof value.attempt === "number" && Number.isFinite(value.attempt); + const maxAttempts = typeof value.maxAttempts === "number" && Number.isFinite(value.maxAttempts); + if (!attempt || !maxAttempts) { + continue; + } + attempts.push({ + attempt: value.attempt as number, + maxAttempts: value.maxAttempts as number, + errorMessage: typeof value.errorMessage === "string" ? value.errorMessage : "", + }); + } + return attempts; +} + +// Streaming-delta commit cadence while the tab is hidden and rAF is frozen. +const HIDDEN_COMMIT_DELAY_MS = 250; + +function isDocumentHidden() { + return typeof document !== "undefined" && document.visibilityState === "hidden"; +} + +function readEventClientRequestId(event: ConversationStreamEvent): string { + const value = (event as { client_request_id?: unknown }).client_request_id; + return typeof value === "string" ? value.trim() : ""; +} + +// Terminals carrying these codes were inferred from missing liveness reports +// (gateway reconcile / desktop ledger sweep) rather than produced by the run +// itself — the run may still be streaming and the settled content incomplete. +function isInferredRunLossErrorCode(errorCode: unknown): boolean { + return ( + errorCode === "desktop_run_lost" || + errorCode === "stale_run" || + errorCode === "agent_offline" || + errorCode === "desktop_runtime_lease_expired" + ); +} + +// Assistant-side content carriers (everything applyDelta folds into a turn's +// entries, except the slot-guarded user_message). Lifecycle events — +// run_started/run_finished/run_queued/rebased/snapshot/tool_status — are not +// content and must always apply. +function isContentDeltaEventType(type: string): boolean { + switch (type) { + case "token": + case "thinking": + case "tool_call": + case "tool_call_delta": + case "tool_result": + case "hosted_search": + case "error": + return true; + default: + return false; + } +} + +export function createTranscriptStore(options?: { + // A stray run_finished arrived for a non-active run while a run is + // streaming: the local view of the run topology diverged from the gateway + // log. Fired at most once per applied sync so the app layer can trigger a + // resubscribe (which re-arms the signal). + onDivergence?: () => void; +}): TranscriptStore { + let historyEntries: ChatEntry[] = []; + let turns: Turn[] = []; + // edit_resend optimistic-truncation stash: the pre-truncation transcript + // captured at submit time so a failed/parked command can restore it locally + // — the offline case where the compensating history refresh cannot run. + // Any authoritative apply (stream event, sync, history snapshot) clears it: + // restoring stale arrays over newer data would corrupt the transcript. + let editResendStash: { + clientRequestId: string; + historyEntries: ChatEntry[]; + turns: Turn[]; + } | null = null; + let activeRun: StreamRunActivity | null = null; + let toolStatus: string | null = null; + let toolStatusIsCompaction = false; + let retryAttempts: readonly RetryAttemptRecord[] = EMPTY_RETRY_ATTEMPTS; + let manualCompactionResult: ManualCompactionResult | null = null; + let foldRevision = 0; + let localTurnSeq = 0; + // Idempotency cursor: the highest log seq already applied. Re-subscribe + // replays and snapshot+replay overlaps are dropped below it. + let lastSeq = 0; + // Debounces onDivergence: one signal per applied sync (reset in applySync), + // and never twice from the same stream position across syncs. + let divergenceSignaled = false; + let lastDivergenceSeq = -1; + // A rebased event whose truncation anchor was not found locally: the server + // authoritatively deleted a settled suffix this transcript still renders + // (the pre-message_ref pile-up bug, lost events, retention gaps). The next + // history snapshot applies as an authoritative replace (alignHistory + // rebaseReconcile) instead of a lag-protective enrich. + let rebaseDivergent = false; + // One resync nudge per unique missing anchor, ever — a replayed miss must + // not ping-pong resubscribes (each resync replays the same failing event). + const signaledRebaseAnchorMisses = new Set(); + + let snapshot = EMPTY_SNAPSHOT; + let dirty = false; + let rafId: number | null = null; + let hiddenCommitTimer: ReturnType | null = null; + const listeners = new Set<() => void>(); + + // Row caches: history rows rebuild only when the history region changes; + // turn rows rebuild only for the turn object that changed (turns are + // immutable-updated, so a token delta re-derives one turn's rows per + // commit, not the whole transcript). The assembled folded-region array is + // additionally cached by identity so the virtualized region's props stay + // referentially stable across streaming commits. + let historyRowsCache: { entries: ChatEntry[]; rows: TranscriptRow[] } | null = null; + const turnRowsCache = new WeakMap(); + let foldedRowsCache: { + historyEntries: ChatEntry[]; + foldedTurns: Turn[]; + rows: TranscriptRow[]; + } | null = null; + + const historyRows = (): TranscriptRow[] => { + if (historyRowsCache?.entries !== historyEntries) { + historyRowsCache = { + entries: historyEntries, + rows: normalizeSettledRowRounds(buildRowsFromEntries(historyEntries, "history")), + }; + } + return historyRowsCache.rows; + }; + + const rowsForTurn = (turn: Turn): TranscriptRow[] => { + let rows = turnRowsCache.get(turn); + if (!rows) { + rows = buildTurnRows(turn); + turnRowsCache.set(turn, rows); + } + return rows; + }; + + const findTurnByCri = (clientRequestId: string): Turn | null => { + if (!clientRequestId) return null; + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn && turn.clientRequestId === clientRequestId) { + return turn; + } + } + return null; + }; + + const findTurnByRunId = (runId: string): Turn | null => { + if (!runId) return null; + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn && turn.runId === runId) { + return turn; + } + } + return null; + }; + + const findStreamingTurn = (): Turn | null => { + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn && turn.phase === "streaming") { + return turn; + } + } + return null; + }; + + const replaceTurn = (previous: Turn, next: Turn) => { + if (previous === next) return; + turns = turns.map((turn) => (turn === previous ? next : turn)); + }; + + // Binds a turn to its run. If a separate turn already exists for that run + // (a seeded `run:` turn created before the ownership was known), the two + // describe the same exchange: absorb its content into `turn` — the user + // bubble's identity always wins — and drop the duplicate. + const adoptRun = (turn: Turn, runId: string): Turn => { + if (!runId || turn.runId === runId) { + return turn; + } + const existing = findTurnByRunId(runId); + let next: Turn = { ...turn, runId }; + if (existing && existing !== turn) { + const ownIds = new Set(next.entries.map((entry) => entry.id)); + const absorbed = existing.entries.filter((entry) => !ownIds.has(entry.id)); + next = { + ...next, + user: next.user ?? existing.user, + entries: absorbed.length ? [...next.entries, ...absorbed] : next.entries, + phase: existing.phase === "streaming" ? "streaming" : next.phase, + }; + turns = turns.filter((candidate) => candidate !== existing); + } + return next; + }; + + const buildSnapshot = (): TranscriptSnapshot => { + const activeTurn = findStreamingTurn(); + // Partition, not prefix: a folded turn can sit after a still-pending one + // in creation order (a foreign run completing while this client's prompt + // waits), and its rows must still land in the virtualized region. + const foldedTurns: Turn[] = []; + const unfoldedTurns: Turn[] = []; + let entryCount = historyEntries.length; + for (const turn of turns) { + (turn.folded ? foldedTurns : unfoldedTurns).push(turn); + entryCount += turn.entries.length + (turn.user ? 1 : 0); + } + + const cacheValid = + foldedRowsCache !== null && + foldedRowsCache.historyEntries === historyEntries && + foldedRowsCache.foldedTurns.length === foldedTurns.length && + foldedRowsCache.foldedTurns.every((turn, index) => turn === foldedTurns[index]); + let foldedRows: TranscriptRow[]; + if (cacheValid && foldedRowsCache) { + foldedRows = foldedRowsCache.rows; + } else { + foldedRows = dedupeRowKeys([ + ...historyRows(), + ...foldedTurns.flatMap((turn) => rowsForTurn(turn)), + ]); + foldedRowsCache = { historyEntries, foldedTurns, rows: foldedRows }; + } + + const seenKeys = new Set(foldedRows.map((row) => row.key)); + const liveRows = dedupeRowKeys( + unfoldedTurns.flatMap((turn) => rowsForTurn(turn)), + seenKeys, + ); + + return { + // When nothing is live the snapshot exposes the cached prefix array + // itself, so idle commits keep full row-list identity. + rows: liveRows.length > 0 ? [...foldedRows, ...liveRows] : foldedRows, + liveStartIndex: liveRows.length > 0 ? foldedRows.length : -1, + activeTurnKey: activeTurn?.key ?? null, + entryCount, + activeRun, + toolStatus, + toolStatusIsCompaction, + retryAttempts, + manualCompactionResult, + needsHistoryRefresh: rebaseDivergent || turns.some((turn) => turn.contentStale === true), + foldRevision, + revision: snapshot.revision + 1, + }; + }; + + const emit = () => { + for (const listener of listeners) { + listener(); + } + }; + const commit = () => { + rafId = null; + if (hiddenCommitTimer !== null) { + clearTimeout(hiddenCommitTimer); + hiddenCommitTimer = null; + } + if (!dirty) { + return; + } + dirty = false; + snapshot = buildSnapshot(); + emit(); + }; + // While a batch is open (applySync), schedule() only marks dirty: the + // snapshot rebuild and the event replay must land as ONE commit, never an + // intermediate frame at the (older) snapshot state. + let batchDepth = 0; + const schedule = (flush?: boolean) => { + dirty = true; + if (batchDepth > 0) { + return; + } + if (flush) { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + commit(); + return; + } + if (isDocumentHidden()) { + // rAF never fires in a hidden tab, so streamed deltas would pile up + // unrendered until refocus. A single coarse timer keeps commits + // flowing (browser throttling may stretch it — that only delays a + // snapshot nobody is looking at); the flush-on-visible listener + // paints the final state the instant the tab comes back. + if (rafId !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(rafId); + rafId = null; + } + if (hiddenCommitTimer === null) { + hiddenCommitTimer = setTimeout(commit, HIDDEN_COMMIT_DELAY_MS); + } + return; + } + if (rafId === null && typeof requestAnimationFrame === "function") { + rafId = requestAnimationFrame(commit); + } else if (typeof requestAnimationFrame !== "function") { + commit(); + } + }; + + // Folding flips flags only: every settled turn moves into the virtualized + // region with identical rows. `settleStreaming` additionally retires a + // still-streaming turn whose run is being superseded by a new run_started. + const foldSettled = (settleStreaming: boolean): boolean => { + let changed = false; + const next = turns.map((turn) => { + if (turn.phase === "settled" && !turn.folded) { + changed = true; + return { ...turn, folded: true }; + } + if (settleStreaming && turn.phase === "streaming") { + changed = true; + return { ...turn, phase: "settled" as const, folded: true }; + } + return turn; + }); + if (changed) { + turns = next; + foldRevision += 1; + } + return changed; + }; + + const setToolStatus = (status: string | null, isCompaction: boolean, flush?: boolean) => { + const next = status && status.trim() ? status.trim() : null; + const nextCompaction = Boolean(next) && isCompaction; + if (toolStatus === next && toolStatusIsCompaction === nextCompaction) { + return; + } + toolStatus = next; + toolStatusIsCompaction = nextCompaction; + schedule(flush); + }; + + const setRetryAttempts = (next: readonly RetryAttemptRecord[], flush?: boolean) => { + if (retryAttempts.length === 0 && next.length === 0) { + return; + } + retryAttempts = next.length === 0 ? EMPTY_RETRY_ATTEMPTS : next; + schedule(flush); + }; + + const applyUserMessage = (event: ConversationStreamEvent, runId: string) => { + const payload = event as { + message?: unknown; + message_id?: unknown; + messageId?: unknown; + uploaded_files?: unknown; + message_ref?: unknown; + }; + const clientRequestId = readEventClientRequestId(event); + const text = typeof payload.message === "string" ? payload.message : ""; + const messageIdRaw = payload.message_id ?? payload.messageId; + const messageId = typeof messageIdRaw === "string" ? messageIdRaw.trim() : ""; + const attachments = normalizeLiveUploadedFiles(payload.uploaded_files); + if (!text.trim() && attachments.length === 0) { + return; + } + // The message's persisted identity (desktop stamps it at persist time): + // the bare id aligns live turns with their history twins, the full ref + // additionally lets a follow-up edit-resend anchor its rebase on this + // turn without waiting for the post-run history refresh. + const messageRef = readHistoryMessageRef(payload.message_ref); + const bindUserIdentity = (user: UserChatEntry): UserChatEntry => { + let next = user; + if (messageId && next.messageId !== messageId) { + next = { ...next, messageId }; + } + if (messageRef && next.messageRef?.messageId !== messageRef.messageId) { + next = { ...next, messageRef }; + } + return next; + }; + if (messageRef) { + // Fresh-open overlap: the fetched history may already render this very + // message's persisted echo while its run replays into a live turn. The + // exchange now belongs to the turn — truncate the region at the echo so + // the prompt renders once. Log eviction is prefix-based, so a replay + // containing this user_message also contains the rest of its run. + const echoIndex = historyEntries.findIndex( + (entry) => entry.kind === "user" && entry.messageRef?.messageId === messageRef.messageId, + ); + if (echoIndex >= 0) { + historyEntries = historyEntries.slice(0, echoIndex); + schedule(true); + } + } + + // (1) Our own submission: bind the optimistic turn to its run. The user + // slot is already filled — the bubble keeps its id, nothing remounts. + const ownTurn = findTurnByCri(clientRequestId); + if (ownTurn) { + let next = adoptRun(ownTurn, runId); + if (!next.user) { + next = { + ...next, + user: { + id: optimisticUserEntryId(clientRequestId), + kind: "user", + text, + attachments, + messageId: messageId || undefined, + messageRef, + timestamp: Date.now(), + }, + }; + } else { + const boundUser = bindUserIdentity(next.user); + if (boundUser !== next.user) { + next = { ...next, user: boundUser }; + } + } + if (next !== ownTurn) { + replaceTurn(ownTurn, next); + schedule(true); + } + return; + } + + // (2) The run already has a turn: fill the single user slot iff empty. + const runTurn = findTurnByRunId(runId); + if (runTurn) { + if (!runTurn.user) { + replaceTurn(runTurn, { + ...runTurn, + user: { + id: seededUserEntryId(runId), + kind: "user", + text, + attachments, + messageId: messageId || undefined, + messageRef, + timestamp: Date.now(), + }, + }); + schedule(true); + } else { + const boundUser = bindUserIdentity(runTurn.user); + if (boundUser !== runTurn.user) { + replaceTurn(runTurn, { ...runTurn, user: boundUser }); + schedule(true); + } + } + return; + } + + // (3) Foreign/seeded turn (another viewer's command, replay). + const seq = readEventSeq(event); + turns = [ + ...turns, + { + ...createTurn({ + key: runId ? `run:${runId}` : `run:seed-${seq}`, + runId, + clientRequestId, + phase: activeRun?.runId === runId && runId !== "" ? "streaming" : "pending", + }), + user: { + id: runId ? seededUserEntryId(runId) : `r:seed-${seq}:u`, + kind: "user", + text, + attachments, + messageId: messageId || undefined, + messageRef, + timestamp: Date.now(), + }, + }, + ]; + schedule(true); + }; + + const applyDelta = (event: ConversationStreamEvent, runId: string) => { + if (event.type === "user_message") { + applyUserMessage(event, runId); + return; + } + let turn = findTurnByRunId(runId) ?? (runId === "" ? findStreamingTurn() : null); + if (!turn && runId === "") { + // Reuse the trailing orphan turn so run-less deltas coalesce into one + // exchange instead of fragmenting into a turn per event. + const last = turns[turns.length - 1]; + if (last && last.key.startsWith("run:orphan") && !last.folded) { + turn = last; + } + } + if (!turn) { + // Content for a run the store never saw start (defensive). + const seq = readEventSeq(event); + turn = createTurn({ + key: runId ? `run:${runId}` : `run:orphan-${seq}`, + runId, + phase: activeRun?.runId === runId && runId !== "" ? "streaming" : "settled", + }); + turns = [...turns, turn]; + } + const next = applyEventToTurn(turn, event as ChatEvent); + if (next !== turn) { + replaceTurn(turn, next); + schedule(false); + } + }; + + const rebuildActiveTurnFromSnapshot = (entriesJson: string, runId: string) => { + const parsed = parseSnapshotEntries(entriesJson); + let turn = + findTurnByRunId(runId) ?? + (activeRun?.clientRequestId ? findTurnByCri(activeRun.clientRequestId) : null); + if (!turn) { + if (parsed.length === 0) { + return; + } + turn = createTurn({ key: `run:${runId}`, runId, phase: "streaming" }); + turns = [...turns, turn]; + } + let next = adoptRun(turn, runId); + next = rebuildTurnFromSnapshot(next, parsed); + if (next.inferredLossErrorEntryId) { + next = { ...next, inferredLossErrorEntryId: undefined }; + } + if (next.phase !== "streaming" || next.folded) { + next = { ...next, phase: "streaming", folded: false }; + } + replaceTurn(turn, next); + schedule(true); + }; + + const applyRunContentSnapshot = (event: ConversationStreamEvent) => { + const runId = readEventRunId(event); + if (!runId) { + return; + } + const payload = event as { + revision?: unknown; + entries_json?: unknown; + content_complete?: unknown; + history_required?: unknown; + }; + const revision = + typeof payload.revision === "number" && Number.isFinite(payload.revision) + ? Math.max(0, Math.floor(payload.revision)) + : 0; + let turn = findTurnByRunId(runId); + if (turn?.terminalContentRevision !== undefined && revision <= turn.terminalContentRevision) { + return; + } + const parsed = parseAuthoritativeSnapshotEntries(payload.entries_json); + const historyRequired = payload.history_required === true; + const contentComplete = + payload.content_complete !== false && !historyRequired && parsed !== null; + if (parsed === null) { + if (turn && turn.contentStale !== true) { + replaceTurn(turn, { ...turn, contentStale: true, contentConfirmed: false }); + schedule(true); + } + return; + } + if (!turn) { + turn = createTurn({ + key: `run:${runId}`, + runId, + phase: activeRun?.runId === runId ? "streaming" : "settled", + }); + turns = [...turns, turn]; + } + const inferredErrorEntryId = turn.inferredLossErrorEntryId; + const realErrorEntries = turn.entries.filter( + (entry): entry is Extract => + (entry.kind === "error" || (entry.kind === "assistant" && entry.id.includes(":err:"))) && + entry.id !== inferredErrorEntryId, + ); + let next = + historyRequired && parsed.length === 0 + ? adoptRun(turn, runId) + : rebuildTurnFromSnapshot(adoptRun(turn, runId), parsed); + if (realErrorEntries.length > 0) { + const errorTexts = new Set( + next.entries + .filter((entry) => entry.kind === "error" || entry.kind === "assistant") + .map((entry) => entry.text), + ); + const missingErrors = realErrorEntries.filter((entry) => !errorTexts.has(entry.text)); + if (missingErrors.length > 0) { + next = { ...next, entries: [...next.entries, ...missingErrors] }; + } + } + next = { + ...next, + phase: turn.phase, + folded: turn.folded, + terminalContentRevision: revision, + contentConfirmed: contentComplete, + contentStale: contentComplete ? false : true, + inferredLossErrorEntryId: undefined, + }; + replaceTurn(turn, next); + schedule(true); + }; + + const applyRunFinished = (event: ConversationStreamEvent) => { + const runId = readEventRunId(event); + const finishesDifferentActiveRun = Boolean( + activeRun && runId !== "" && runId !== activeRun.runId, + ); + if (finishesDifferentActiveRun) { + // Stray terminal for a non-active run (the gateway appends these + // deliberately, e.g. failing a superseded queued run). A reliable + // terminal for an older run is still allowed to settle/correct that + // run, but it must never touch the currently active run. + const stray = findTurnByRunId(runId); + const reason = (event as { reason?: unknown }).reason; + if (reason === "superseded" && stray && !stray.folded) { + turns = turns.filter((turn) => turn !== stray); + schedule(true); + if (!divergenceSignaled && lastSeq !== lastDivergenceSeq) { + divergenceSignaled = true; + lastDivergenceSeq = lastSeq; + options?.onDivergence?.(); + } + return; + } + // The active run may itself be a zombie (its own run_finished was + // lost); let the app resync this conversation to converge. The seq + // guard stops a resync loop when a reset replay re-delivers the same + // stray on every subscribe. + if (!stray) { + if (!divergenceSignaled && lastSeq !== lastDivergenceSeq) { + divergenceSignaled = true; + lastDivergenceSeq = lastSeq; + options?.onDivergence?.(); + } + return; + } + } + const payload = event as { + status?: string; + message?: string; + reason?: string; + error_code?: string; + }; + const inferredLoss = + payload.status === "failed" && isInferredRunLossErrorCode(payload.error_code); + let turn = findTurnByRunId(runId) ?? findStreamingTurn(); + if (turn && !inferredLoss && turn.inferredLossErrorEntryId) { + const previousTurn = turn; + const correctedTurn = { + ...previousTurn, + entries: previousTurn.entries.filter( + (entry) => entry.id !== previousTurn.inferredLossErrorEntryId, + ), + inferredLossErrorEntryId: undefined, + }; + replaceTurn(previousTurn, correctedTurn); + turn = correctedTurn; + } + if (payload.status === "failed" && payload.message && payload.reason !== "superseded") { + if (!turn) { + turn = createTurn({ key: `run:${runId || `finished-${readEventSeq(event)}`}`, runId }); + turns = [...turns, turn]; + } + const previousEntryIds = new Set(turn.entries.map((entry) => entry.id)); + let withError = applyEventToTurn(turn, { + type: "error", + message: payload.message, + } as ChatEvent); + if (inferredLoss) { + const inferredErrorEntry = withError.entries.find( + (entry) => !previousEntryIds.has(entry.id), + ); + if (inferredErrorEntry) { + withError = { + ...withError, + inferredLossErrorEntryId: inferredErrorEntry.id, + }; + } + } + replaceTurn(turn, withError); + turn = withError; + } + // A failure inferred from missing liveness reports (desktop_run_lost & + // co) may have cut the stream mid-reply, so the streamed copy cannot be + // trusted as the full content: mark it stale so the post-persist history + // refresh may adopt the real reply (enrichTurnFromHistory). A gateway + // resurrection rebuilds from the runtime snapshot and clears the mark. + const shouldMarkContentStale = inferredLoss || turn?.contentConfirmed !== true; + if (turn && (turn.phase !== "settled" || turn.contentStale !== shouldMarkContentStale)) { + replaceTurn(turn, { + ...turn, + phase: "settled", + contentStale: shouldMarkContentStale, + }); + } + if (!finishesDifferentActiveRun) { + activeRun = null; + setToolStatus(null, false); + setRetryAttempts(EMPTY_RETRY_ATTEMPTS); + } + schedule(true); + }; + + // edit_resend: truncate the transcript at the edited user message. This is + // shared by the synchronous optimistic path and the authoritative stream + // event so the latter remains idempotent when it arrives. + const readRebaseAnchor = (ref: unknown): { messageId: string; contentHash: string } | null => { + if (!ref || typeof ref !== "object") { + return null; + } + const refValue = ref as Record; + const rawMessageId = refValue.message_id ?? refValue.messageId; + const messageId = typeof rawMessageId === "string" ? rawMessageId.trim() : ""; + const rawContentHash = refValue.content_hash ?? refValue.contentHash; + const contentHash = typeof rawContentHash === "string" ? rawContentHash.trim() : ""; + if (!messageId && !contentHash) { + return null; + } + return { messageId, contentHash }; + }; + + const rebaseFromMessageRef = (ref: unknown): boolean => { + const anchor = readRebaseAnchor(ref); + if (!anchor) { + return false; + } + const { messageId, contentHash } = anchor; + // Prefer the exact message id; the content hash is only a fallback for + // refs without one — matching on it eagerly would truncate at the FIRST + // occurrence of a re-sent identical prompt. + const matchesRef = (user: UserChatEntry | null) => { + if (!user?.messageRef) { + return false; + } + if (messageId !== "") { + return user.messageRef.messageId === messageId; + } + return contentHash !== "" && user.messageRef.contentHash === contentHash; + }; + + const turnIndex = turns.findIndex((turn) => matchesRef(turn.user)); + if (turnIndex >= 0) { + turns = [ + ...turns.slice(0, turnIndex), + ...turns.slice(turnIndex).filter((turn) => turn.phase === "pending"), + ]; + return true; + } + + const entryIndex = historyEntries.findIndex( + (entry) => entry.kind === "user" && matchesRef(entry), + ); + if (entryIndex < 0) { + return false; + } + historyEntries = historyEntries.slice(0, entryIndex); + turns = turns.filter((turn) => turn.phase === "pending"); + return true; + }; + + const applyRebased = (event: ConversationStreamEvent) => { + const ref = (event as { base_message_ref?: unknown }).base_message_ref; + if (rebaseFromMessageRef(ref)) { + schedule(true); + return; + } + const anchor = readRebaseAnchor(ref); + if (!anchor) { + return; + } + // The truncation anchor is nowhere in this transcript (its turn was + // created before the persisted identity was known, or the anchor events + // fell out of the buffer). Silently keeping the tail is exactly how old + // edit versions pile up as extra user bubbles: mark the transcript + // rebase-divergent so the next history snapshot applies as an + // authoritative replace, and nudge one resync per unique anchor in case + // the anchor-bearing events were merely lost from this subscription. + rebaseDivergent = true; + schedule(true); + const anchorKey = anchor.messageId || anchor.contentHash; + if (!divergenceSignaled && !signaledRebaseAnchorMisses.has(anchorKey)) { + divergenceSignaled = true; + signaledRebaseAnchorMisses.add(anchorKey); + options?.onDivergence?.(); + } + }; + + function applyOne(event: ConversationStreamEvent) { + editResendStash = null; + const seq = readEventSeq(event); + if (seq > 0) { + if (seq <= lastSeq) { + // Already applied (resubscribe replay / snapshot overlap). + return; + } + lastSeq = seq; + } + const runId = readEventRunId(event); + switch (event.type) { + case "run_started": { + // Fold the previous exchange into the virtualized region in the same + // commit that renders the new run — the one intentional layout change. + foldSettled(true); + const clientRequestId = readEventClientRequestId(event); + let turn = findTurnByCri(clientRequestId) ?? findTurnByRunId(runId); + if (!turn) { + turn = createTurn({ key: `run:${runId}`, runId, clientRequestId }); + turns = [...turns, turn]; + } + const bound = adoptRun(turn, runId); + const entries = bound.inferredLossErrorEntryId + ? bound.entries.filter((entry) => entry.id !== bound.inferredLossErrorEntryId) + : bound.entries; + replaceTurn(turn, { + ...bound, + entries, + phase: "streaming", + folded: false, + inferredLossErrorEntryId: undefined, + }); + activeRun = { + runId, + state: "running", + startedSeq: seq, + toolStatus: null, + toolStatusIsCompaction: false, + clientRequestId: clientRequestId || undefined, + updatedAt: Date.now(), + }; + setToolStatus(null, false, true); + setRetryAttempts(EMPTY_RETRY_ATTEMPTS, true); + schedule(true); + return; + } + case "run_finished": { + applyRunFinished(event); + return; + } + case "run_content_snapshot": { + applyRunContentSnapshot(event); + return; + } + case "run_queued": { + // The prompt went into the desktop queue: drop its turn (user bubble + // and provisional entries); the queue panel shows it instead. + const turn = findTurnByRunId(runId) ?? findTurnByCri(readEventClientRequestId(event)); + if (turn && !turn.folded) { + turns = turns.filter((candidate) => candidate !== turn); + schedule(true); + } + if (activeRun?.runId === runId) { + activeRun = null; + schedule(true); + } + return; + } + case "rebased": { + applyRebased(event); + return; + } + case "snapshot": { + const payload = event as { entries_json?: string; as_of_seq?: number }; + const asOfSeq = + typeof payload.as_of_seq === "number" && Number.isFinite(payload.as_of_seq) + ? Math.floor(payload.as_of_seq) + : 0; + if (asOfSeq > 0 && asOfSeq <= lastSeq) { + // Snapshot events carry no seq of their own; a replayed one that + // covers less of the log than we already applied must not roll the + // active turn back to an older state. + return; + } + rebuildActiveTurnFromSnapshot(payload.entries_json ?? "", runId); + if (asOfSeq > 0) { + // The snapshot content covers the log through as_of_seq; drop the + // overlapping tail of any concurrent replay. + lastSeq = Math.max(lastSeq, asOfSeq); + } + const status = (event as { tool_status?: string | null }).tool_status ?? null; + setToolStatus( + typeof status === "string" ? status : null, + (event as { tool_status_is_compaction?: boolean }).tool_status_is_compaction === true, + true, + ); + return; + } + case "tool_status": { + const status = (event as { status?: string | null }).status ?? null; + setToolStatus( + typeof status === "string" ? status : null, + (event as { isCompaction?: boolean }).isCompaction === true, + ); + const nextRetryAttempts = normalizeRetryAttempts( + (event as { retryAttempts?: unknown }).retryAttempts, + ); + if (nextRetryAttempts !== null) { + setRetryAttempts(nextRetryAttempts); + } + if (activeRun && activeRun.runId === runId) { + activeRun = { ...activeRun, toolStatus, toolStatusIsCompaction }; + } + return; + } + case "manual_compaction_result": { + // 本分支是 switch 里唯一直读载荷形状的地方,必须与相邻 case 一样防御式 + // 解构:可靠 ingress journal 会重放本帧,缺字段的畸形帧若抛 TypeError 会 + // 在每次重订阅时复现并打断整条应用链。operationId 非字符串/空、status 不在 + // 白名单内、message 非字符串——一律降级(丢帧或空串),永不抛错。 + const rawOperationId = (event as { operationId?: unknown }).operationId; + const operationId = typeof rawOperationId === "string" ? rawOperationId.trim() : ""; + if (!operationId) return; + const rawStatus = (event as { status?: unknown }).status; + if ( + rawStatus !== "compacted" && + rawStatus !== "failed" && + rawStatus !== "busy" && + rawStatus !== "skipped" + ) { + return; + } + const rawMessage = (event as { message?: unknown }).message; + manualCompactionResult = { + operationId, + status: rawStatus, + message: typeof rawMessage === "string" ? rawMessage.trim() : "", + }; + schedule(true); + return; + } + default: { + applyDelta(event, runId); + } + } + } + + const applySyncLocked = (result: ConversationSubscribeResult) => { + // A completed (re)subscribe is the convergence point: re-arm the + // divergence signal so a later stray burst can trigger another resync. + divergenceSignaled = false; + // Runs whose settled turn kept its streamed entries across a reset (the + // replay cannot rebuild them from the start): their replayed content + // deltas must not re-apply on top of the kept entries. + const suppressedReplayDeltaRuns = new Set(); + if (result.reset) { + // Seq continuity broke (gateway restart / buffer gap). Folded and + // settled turns hold finished content with stable ids — fold, never + // drop. A streaming turn's content is rebuilt from the snapshot and + // replay, but the turn object (and its user bubble) survives, so the + // active exchange never remounts. Pending turns — optimistic echoes + // whose run hasn't started — survive untouched and bind normally + // when their seed replays. + lastSeq = 0; + let changed = false; + turns = turns.map((turn) => { + if (turn.phase === "settled" && !turn.folded) { + changed = true; + return { ...turn, folded: true }; + } + if (turn.phase === "streaming") { + changed = true; + const replayRebuildsRun = + turn.runId !== "" && + result.events.some( + (event) => event.type === "run_started" && readEventRunId(event) === turn.runId, + ); + if (result.activity && result.activity.runId === turn.runId) { + // Still running server-side: the snapshot/replay rebuilds the + // content into this same turn object. When the incoming snapshot + // targets this run, keep the delta-built entries so the rebuild + // can compare per-tool-call progress instead of starting blind. + // Without a snapshot or a from-the-start replay the rebuild is + // partial: mark it stale so the post-run enrich adopts the + // persisted reply wholesale. + if (result.snapshot?.runId === turn.runId) { + return turn; + } + return { ...turn, entries: [], contentStale: !replayRebuildsRun }; + } + // The run ended while this client was away: settle the turn (never + // strand it as a pending zombie). + if (result.activity) { + // Superseded by the now-active run: rebuild this turn's content + // from history via the idle enrich after the active run settles + // (that busy window outlasts the desktop's post-run flush). + return { ...turn, entries: [], phase: "settled" as const }; + } + // Nothing is running server-side, so the enrich fires immediately + // and can race the desktop's post-run flush. Clear the content only + // when the replay rebuilds it from the run's start — otherwise the + // kept streamed entries are the reply's only copy. They may still + // be incomplete, so they are marked stale: the enrich adopts the + // persisted reply wholesale once the flush lands. + if (replayRebuildsRun || turn.entries.length === 0) { + return { ...turn, entries: [], phase: "settled" as const }; + } + if (turn.runId !== "") { + suppressedReplayDeltaRuns.add(turn.runId); + } + return { ...turn, phase: "settled" as const, contentStale: true }; + } + return turn; + }); + if (changed) { + foldRevision += 1; + } + toolStatus = null; + toolStatusIsCompaction = false; + retryAttempts = EMPTY_RETRY_ATTEMPTS; + // Set the activity before the rebuild so the snapshot can target the + // optimistic pending turn by client_request_id (its user bubble then + // keeps its identity instead of a duplicate run turn appearing). + activeRun = result.activity; + if (result.snapshot) { + rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); + } + } else { + activeRun = result.activity; + if (result.snapshot) { + // Late join mid-run where the buffer cannot cover the run start. + // The snapshot folds every event through asOfSeq into its entries; + // advancing the cursor drops the overlapping replay below. + const existing = findTurnByRunId(result.snapshot.runId); + if (!existing || existing.entries.length === 0) { + rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); + } + } + } + if (result.activity) { + setToolStatus(result.activity.toolStatus, result.activity.toolStatusIsCompaction); + } else if (result.reset) { + setToolStatus(null, false); + } + for (const event of result.events) { + if ( + suppressedReplayDeltaRuns.size > 0 && + isContentDeltaEventType(event.type) && + suppressedReplayDeltaRuns.has(readEventRunId(event)) + ) { + // The turn kept its streamed entries; re-applying a partial replay + // tail would double-append. Only advance the idempotency cursor. + const seq = readEventSeq(event); + if (seq > lastSeq) { + lastSeq = seq; + } + continue; + } + applyOne(event); + } + // history.get and chat.subscribe race when a conversation is opened. + // If history painted first, the replay/snapshot above has only now + // materialized the active turn; run the same guarded replace alignment + // once more so the persisted copy of that running exchange is removed. + // This is deliberately gated on a user-bearing active turn: an + // assistant-only mid-run snapshot cannot be paired safely by position. + if ( + historyEntries.length > 0 && + turns.some( + (turn) => (turn.phase === "pending" || turn.phase === "streaming") && turn.user !== null, + ) + ) { + const aligned = alignHistory({ + historyEntries, + turns, + entries: historyEntries, + mode: "replace", + }); + if (aligned.changed) { + historyEntries = aligned.historyEntries; + turns = aligned.turns; + } + } + lastSeq = Math.max(lastSeq, result.latestSeq); + }; + + return { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + // The rebuild + replay of a (re)subscribe commits as one frame: an + // intermediate render at the snapshot's older state is exactly the + // backwards flicker the progress guards exist to prevent. + applySync: (result) => { + editResendStash = null; + batchDepth += 1; + try { + applySyncLocked(result); + } finally { + batchDepth -= 1; + } + schedule(true); + }, + + applyEvent: (event) => { + applyOne(event); + }, + + addOptimisticUserEntry: ({ clientRequestId, text, attachments, baseMessageRef }) => { + const preRebaseHistoryEntries = historyEntries; + const preRebaseTurns = turns; + const rebased = baseMessageRef ? rebaseFromMessageRef(baseMessageRef) : false; + if (rebased) { + // Both arrays are replaced (never mutated) by the truncation, so the + // captured references are the intact pre-truncation transcript. + editResendStash = { + clientRequestId, + historyEntries: preRebaseHistoryEntries, + turns: preRebaseTurns, + }; + } + if (findTurnByCri(clientRequestId)) { + if (rebased) { + schedule(true); + } + return; + } + turns = [ + ...turns, + { + ...createTurn({ key: `req:${clientRequestId}`, clientRequestId, phase: "pending" }), + user: { + id: optimisticUserEntryId(clientRequestId), + kind: "user", + text, + attachments: attachments ?? [], + timestamp: Date.now(), + }, + }, + ]; + schedule(true); + }, + + // edit_resend compensation: put back the transcript captured at submit + // time. Returns false when authoritative data has superseded the stash — + // the caller then relies on the network history refresh instead. + restoreEditResendTranscript: (clientRequestId) => { + if (!editResendStash || editResendStash.clientRequestId !== clientRequestId) { + return false; + } + historyEntries = editResendStash.historyEntries; + turns = editResendStash.turns; + editResendStash = null; + schedule(true); + return true; + }, + + removeOptimisticUserEntry: (clientRequestId) => { + const turn = findTurnByCri(clientRequestId); + if (!turn || turn.folded) { + return; + } + if (turn.entries.length === 0) { + turns = turns.filter((candidate) => candidate !== turn); + } else if (turn.user) { + // Keep the run's content but settle a headless pending turn — it can + // never bind again, and a lingering pending turn would block the + // idle history enrich. + replaceTurn(turn, { + ...turn, + user: null, + phase: turn.phase === "streaming" ? turn.phase : "settled", + }); + } else { + return; + } + schedule(true); + }, + + appendLocalError: (message) => { + const alreadyShown = turns.some( + (turn) => + !turn.folded && + turn.entries.some( + (entry) => + (entry.kind === "assistant" || entry.kind === "error") && + entry.text.trim() === message.trim(), + ), + ); + if (alreadyShown) { + return; + } + const turn = createTurn({ key: `local:${localTurnSeq++}`, phase: "settled" }); + const withError = applyEventToTurn(turn, { type: "error", message } as ChatEvent); + if (withError.entries.length === 0) { + return; + } + turns = [...turns, withError]; + schedule(true); + }, + + applyHistorySnapshot: (entries, options) => { + editResendStash = null; + // After a rebase whose anchor was missing, the transcript still renders + // settled turns the server already deleted. The lag-protective enrich + // would keep them forever — apply this snapshot as an authoritative + // replace that drops uncovered ref-less settled turns instead + // (persist-lag protections for genuinely lagged replies still apply). + const rebaseReconcile = rebaseDivergent; + rebaseDivergent = false; + const result = alignHistory({ + historyEntries, + turns, + entries, + mode: rebaseReconcile ? "replace" : (options?.mode ?? "enrich"), + rebaseReconcile, + }); + if (!result.changed) { + return; + } + historyEntries = result.historyEntries; + turns = result.turns; + schedule(true); + }, + + foldSettledTurns: () => { + if (foldSettled(false)) { + schedule(true); + } + }, + + flush: () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + commit(); + }, + }; +} + +function isSnapshotChatEntry(value: unknown): value is ChatEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const v = value as Record; + if (typeof v.id !== "string" || typeof v.kind !== "string") { + return false; + } + switch (v.kind) { + case "user": + return typeof v.text === "string" && Array.isArray(v.attachments); + case "assistant": + case "thinking": + case "error": + return typeof v.text === "string"; + case "tool_call": + return v.toolCall != null && typeof v.toolCall === "object"; + case "tool_result": + return v.toolResult != null && typeof v.toolResult === "object"; + case "hosted_search": + return v.hostedSearch != null && typeof v.hostedSearch === "object"; + default: + return false; + } +} + +function parseSnapshotEntries(json: string | undefined): ChatEntry[] { + const raw = typeof json === "string" ? json.trim() : ""; + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isSnapshotChatEntry) : []; + } catch { + return []; + } +} + +function parseAuthoritativeSnapshotEntries(json: unknown): ChatEntry[] | null { + const raw = typeof json === "string" ? json.trim() : ""; + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) && parsed.every(isSnapshotChatEntry) ? parsed : null; + } catch { + return null; + } +} diff --git a/crates/agent-mobile/src/lib/chat/transcript/turnReducer.ts b/crates/agent-mobile/src/lib/chat/transcript/turnReducer.ts new file mode 100644 index 000000000..660a14590 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/transcript/turnReducer.ts @@ -0,0 +1,736 @@ +import { + enrichHostedSearchBlockWithText, + mergeHostedSearchBlocks, + normalizeHostedSearchBlock, +} from "@liveagent/ui/lib/chat/hostedSearch"; +import { toolArgsProgress } from "@liveagent/ui/lib/chat/toolPreview"; +import type { ToolCall } from "@/lib/agentTypes"; +import { isAbortLikeError } from "@/lib/chat/chatPageHelpers"; +import { summarizeToolCall } from "@/lib/chat/uiMessages"; +import { + type AssistantMeta, + buildAssistantMeta, + buildHostedSearchEntry, + buildToolCallEntry, + buildToolResultEntry, + type ChatEntry, + formatLiveErrorMessage, + hashText, + hashValue, + isCheckpointTokenEvent, + isMatchingToolCallEntry, + isMatchingToolResultEntry, + normalizeCheckpointEntry, + normalizeToolArguments, + normalizeToolCallLike, + safeStringify, + stripRecoveredToolCallMarkup, +} from "@/lib/chatUi"; +import type { ChatEvent } from "@/lib/gatewayTypes"; + +import type { Turn, TurnPhase } from "./types"; + +// 合并 assistant meta:只用已定义字段覆盖,值为 undefined 的键一律跳过。 +// buildAssistantMeta 现已增量构建(不再物化 own-undefined 键),这里再加一道 +// 防御,保证未来任何生产者送来的 meta 都不会用 undefined 抹掉此前事件送达的 +// contextUsageTokens/usageTotalTokens 等锚点(issue #359 缺陷 #2)。 +function mergeAssistantMeta(base: AssistantMeta | undefined, patch: AssistantMeta): AssistantMeta { + const merged: AssistantMeta = { ...(base ?? {}) }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + (merged as Record)[key] = value; + } + } + return merged; +} + +// Applies one assistant-side stream event to a turn. Every merge/dedup scan +// is bounded by the turn (and, within it, the segment since the last +// checkpoint/error entry) — the turn boundary replaces every "walk the tail +// back to the last user bubble" scan of the old flat-entry pipeline. +// +// Entry ids are deterministic in the run namespace (`r::…`) so a +// resubscribe replay rebuilds identical ids and nothing remounts. + +export function optimisticUserEntryId(clientRequestId: string): string { + return `ou:${clientRequestId}`; +} + +export function seededUserEntryId(runId: string): string { + return `r:${runId}:u`; +} + +export function createTurn(params: { + key: string; + runId?: string; + clientRequestId?: string; + user?: Turn["user"]; + phase?: TurnPhase; +}): Turn { + return { + key: params.key, + runId: params.runId ?? "", + clientRequestId: params.clientRequestId ?? "", + user: params.user ?? null, + entries: [], + phase: params.phase ?? "pending", + folded: false, + }; +} + +function turnNamespace(turn: Turn): string { + return `r:${turn.runId || turn.key}`; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function readRound(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return value > 0 ? Math.floor(value) : undefined; +} + +function recordHasEntries(value: Record): boolean { + return Object.keys(value).length > 0; +} + +// Start index of the current segment: entries after the last checkpoint or +// standalone error entry (both flush the assistant group in the row builder, +// so merges must not reach across them). +function segmentStartIndex(entries: ChatEntry[]): number { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const kind = entries[index]?.kind; + if (kind === "checkpoint" || kind === "error") { + return index + 1; + } + } + return 0; +} + +function findLastSegmentAssistantIndex(entries: ChatEntry[], round?: number): number { + const start = segmentStartIndex(entries); + for (let index = entries.length - 1; index >= start; index -= 1) { + const entry = entries[index]; + if (entry?.kind === "assistant" && (round === undefined || entry.round === round)) { + return index; + } + } + return -1; +} + +function countSegmentEntries(entries: ChatEntry[], matcher: (entry: ChatEntry) => boolean): number { + const start = segmentStartIndex(entries); + let count = 0; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry && matcher(entry)) { + count += 1; + } + } + return count; +} + +function countTurnEntries(entries: ChatEntry[], matcher: (entry: ChatEntry) => boolean): number { + let count = 0; + for (const entry of entries) { + if (matcher(entry)) { + count += 1; + } + } + return count; +} + +function segmentHasAssistantText(entries: ChatEntry[]): boolean { + const start = segmentStartIndex(entries); + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind === "assistant" && entry.text.trim()) { + return true; + } + } + return false; +} + +function hasSegmentEntry(entries: ChatEntry[], matcher: (entry: ChatEntry) => boolean): boolean { + const start = segmentStartIndex(entries); + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry && matcher(entry)) { + return true; + } + } + return false; +} + +// Fills empty tool-call arguments in place when a later event carries the +// full payload (tool_call_delta upgrades, tool_result argument echoes). +function mergeSegmentToolCallArguments( + entries: ChatEntry[], + params: { + id?: unknown; + name?: unknown; + arguments?: unknown; + round?: number; + }, +): { entries: ChatEntry[]; matched: boolean } { + const incomingArgs = normalizeToolArguments(params.arguments); + const hasIncomingArgs = recordHasEntries(incomingArgs); + const incomingId = readString(params.id).trim(); + const incomingName = readString(params.name).trim(); + const start = segmentStartIndex(entries); + + for (let index = entries.length - 1; index >= start; index -= 1) { + const entry = entries[index]; + if (entry?.kind !== "tool_call") { + continue; + } + if (params.round !== undefined && entry.round !== params.round) { + continue; + } + + const existingArgs = normalizeToolArguments(entry.toolCall.arguments); + const hasExistingArgs = recordHasEntries(existingArgs); + let matches = false; + let canUpdate = false; + + if (incomingId !== "") { + matches = entry.toolCall.id === incomingId; + if (matches && hasIncomingArgs) { + // Monotonic guard for file tools: streamed args only grow, so a + // lower-progress writer (late delta replay, lagging snapshot echo) + // must never roll the entry back. + const incomingProgress = toolArgsProgress( + incomingName || entry.toolCall.name, + incomingArgs, + ); + const existingProgress = toolArgsProgress(entry.toolCall.name, existingArgs); + const regressed = + incomingProgress !== undefined && + existingProgress !== undefined && + incomingProgress < existingProgress; + canUpdate = !regressed && safeStringify(existingArgs) !== safeStringify(incomingArgs); + } + } else if (incomingName !== "" && entry.toolCall.name === incomingName) { + const sameArguments = safeStringify(existingArgs) === safeStringify(incomingArgs); + matches = sameArguments || (!hasExistingArgs && hasIncomingArgs); + canUpdate = matches && !hasExistingArgs && hasIncomingArgs; + } + + if (!matches) { + continue; + } + if (!canUpdate) { + return { entries, matched: true }; + } + + const nextToolCall = { + ...entry.toolCall, + id: incomingId || entry.toolCall.id, + name: incomingName || entry.toolCall.name, + arguments: incomingArgs, + } as ToolCall; + const next = entries.slice(); + next[index] = { + ...entry, + toolCall: nextToolCall, + summary: summarizeToolCall(nextToolCall), + text: safeStringify(nextToolCall.arguments), + }; + return { entries: next, matched: true }; + } + + return { entries, matched: false }; +} + +// Hosted-search blocks stream before the text that cites them; once the +// segment has assistant text, backfill citation sources into source-less +// search entries. +function enrichSegmentHostedSearches(entries: ChatEntry[]): ChatEntry[] { + const start = segmentStartIndex(entries); + + // Cheap pre-check: without a source-less search there is nothing to + // enrich, and this runs after every token append. + let hasSourcelessSearch = false; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind === "hosted_search" && entry.hostedSearch.sources.length === 0) { + hasSourcelessSearch = true; + break; + } + } + if (!hasSourcelessSearch) { + return entries; + } + + let allText = ""; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind === "assistant") { + allText += entry.text; + } + } + if (allText === "") { + return entries; + } + + let next: ChatEntry[] | null = null; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind !== "hosted_search" || entry.hostedSearch.sources.length > 0) { + continue; + } + + let nextSearchIndex = entries.length; + for (let probe = index + 1; probe < entries.length; probe += 1) { + if (entries[probe]?.kind === "hosted_search") { + nextSearchIndex = probe; + break; + } + } + + let nearbyText = ""; + for (let probe = index + 1; probe < nextSearchIndex; probe += 1) { + const textEntry = entries[probe]; + if (textEntry?.kind === "assistant") { + nearbyText += textEntry.text; + } + } + + const enriched = enrichHostedSearchBlockWithText(entry.hostedSearch, nearbyText || allText); + if (enriched.sources.length === entry.hostedSearch.sources.length) { + continue; + } + + if (!next) { + next = entries.slice(); + } + next[index] = { ...entry, hostedSearch: enriched }; + } + + return next ?? entries; +} + +function withEntries(turn: Turn, entries: ChatEntry[]): Turn { + return entries === turn.entries ? turn : { ...turn, entries }; +} + +function applyTokenEvent(turn: Turn, event: Extract): Turn { + if (isCheckpointTokenEvent(event)) { + const checkpoint = normalizeCheckpointEntry({ + id: event.checkpoint?.summaryId, + content: event.text, + timestamp: event.checkpoint?.timestamp, + summaryMeta: { + coveredMessageCount: event.checkpoint?.coveredMessageCount, + generatedBy: event.checkpoint?.generatedBy, + }, + checkpoint: event.checkpoint, + fallbackId: `${turnNamespace(turn)}:cp:${countTurnEntries( + turn.entries, + (entry) => entry.kind === "checkpoint", + )}`, + }); + if ( + !checkpoint || + turn.entries.some( + (entry) => entry.kind === "checkpoint" && entry.summaryId === checkpoint.summaryId, + ) + ) { + return turn; + } + return withEntries(turn, [...turn.entries, checkpoint]); + } + + const text = stripRecoveredToolCallMarkup(event.text ?? ""); + const round = readRound(event.round); + const meta = buildAssistantMeta({ + provider: event.provider, + model: event.model, + api: event.api, + stopReason: event.stopReason, + usage: event.usage, + contextUsageTokens: event.contextUsageTokens, + contextRelevant: event.contextRelevant, + }); + + if (text === "" && !meta) { + return turn; + } + + const entries = turn.entries; + const tail = entries.at(-1); + const assistantIndex = + tail?.kind === "assistant" && (round === undefined || tail.round === round) + ? entries.length - 1 + : text === "" + ? findLastSegmentAssistantIndex(entries, round) + : -1; + if (assistantIndex >= 0) { + const target = entries[assistantIndex]; + if (target?.kind !== "assistant") return turn; + const next = entries.slice(); + next[assistantIndex] = { + ...target, + text: target.text + text, + round: round ?? target.round, + meta: meta ? mergeAssistantMeta(target.meta, meta) : target.meta, + }; + return withEntries(turn, enrichSegmentHostedSearches(next)); + } + + const occurrence = countSegmentEntries( + entries, + (entry) => entry.kind === "assistant" && entry.round === round, + ); + const next: ChatEntry[] = [ + ...entries, + { + id: `${turnNamespace(turn)}:a:${round ?? 0}:${occurrence}`, + kind: "assistant", + text, + round, + meta, + }, + ]; + return withEntries(turn, enrichSegmentHostedSearches(next)); +} + +function applyThinkingEvent(turn: Turn, event: Extract): Turn { + const text = stripRecoveredToolCallMarkup(event.text ?? ""); + if (text === "") { + return turn; + } + const round = readRound(event.round); + const entries = turn.entries; + const last = entries.at(-1); + if (last?.kind === "thinking" && last.round === round) { + return withEntries(turn, [...entries.slice(0, -1), { ...last, text: last.text + text }]); + } + const occurrence = countSegmentEntries( + entries, + (entry) => entry.kind === "thinking" && entry.round === round, + ); + return withEntries(turn, [ + ...entries, + { + id: `${turnNamespace(turn)}:th:${round ?? 0}:${occurrence}`, + kind: "thinking", + round, + text, + }, + ]); +} + +function applyToolCallEvent( + turn: Turn, + event: Extract, +): Turn { + const round = readRound(event.round); + const eventToolCall = normalizeToolCallLike(event); + const merged = mergeSegmentToolCallArguments(turn.entries, { + id: eventToolCall.id, + name: eventToolCall.name, + arguments: eventToolCall.arguments, + round, + }); + if (merged.matched) { + return withEntries(turn, merged.entries); + } + + const explicitId = readString(eventToolCall.id).trim(); + const baseId = explicitId + ? `${turnNamespace(turn)}:tc:${round ?? 0}:${explicitId}` + : `${turnNamespace(turn)}:tc:${round ?? 0}:${readString(eventToolCall.name).trim() || "Tool"}:${hashValue( + normalizeToolArguments(eventToolCall.arguments), + )}`; + const occurrence = countTurnEntries( + turn.entries, + (entry) => entry.kind === "tool_call" && entry.id.startsWith(baseId), + ); + const stableId = `${baseId}:${occurrence}`; + + return withEntries(turn, [ + ...turn.entries, + buildToolCallEntry( + { + id: eventToolCall.id, + name: eventToolCall.name, + arguments: eventToolCall.arguments, + }, + round, + { entryId: stableId, fallbackToolCallId: stableId }, + ), + ]); +} + +function applyToolResultEvent( + turn: Turn, + event: Extract, +): Turn { + const round = readRound(event.round); + const resultToolCall = normalizeToolCallLike(event); + const hasResultToolCallArgs = recordHasEntries(normalizeToolArguments(resultToolCall.arguments)); + const merged = mergeSegmentToolCallArguments(turn.entries, { + id: resultToolCall.id, + name: resultToolCall.name, + arguments: resultToolCall.arguments, + round, + }); + const entries = merged.entries; + if ( + hasSegmentEntry(entries, (entry) => + isMatchingToolResultEntry(entry, { + toolCallId: resultToolCall.id ?? event.id, + toolName: resultToolCall.name ?? event.name, + content: event.content, + isError: event.isError, + round, + }), + ) + ) { + return withEntries(turn, entries); + } + + const explicitId = readString(resultToolCall.id ?? event.id).trim(); + const baseId = explicitId + ? `${turnNamespace(turn)}:tr:${round ?? 0}:${explicitId}` + : `${turnNamespace(turn)}:tr:${round ?? 0}:${readString(resultToolCall.name ?? event.name).trim() || "Tool"}:${ + event.isError ? "error" : "ok" + }:${hashValue(event.content)}`; + const occurrence = countTurnEntries( + turn.entries, + (entry) => entry.kind === "tool_result" && entry.id.startsWith(baseId), + ); + const stableId = `${baseId}:${occurrence}`; + const shouldPrependToolCall = + hasResultToolCallArgs && + !merged.matched && + !hasSegmentEntry(entries, (entry) => + isMatchingToolCallEntry(entry, { + id: resultToolCall.id, + name: resultToolCall.name, + arguments: resultToolCall.arguments, + round, + }), + ); + + return withEntries(turn, [ + ...entries, + ...(shouldPrependToolCall + ? [ + buildToolCallEntry( + { + id: resultToolCall.id, + name: resultToolCall.name, + arguments: resultToolCall.arguments, + }, + round, + { + entryId: `${stableId}:tc`, + fallbackToolCallId: `${stableId}:tc`, + }, + ), + ] + : []), + buildToolResultEntry( + { + toolCallId: resultToolCall.id ?? event.id, + toolName: resultToolCall.name ?? event.name, + content: event.content, + details: event.details, + isError: event.isError, + }, + round, + { entryId: stableId, fallbackToolCallId: stableId }, + ), + ]); +} + +function applyHostedSearchEvent( + turn: Turn, + event: Extract, +): Turn { + const round = readRound(event.round); + const hostedSearch = normalizeHostedSearchBlock({ + type: "hostedSearch", + id: event.id, + provider: event.provider, + status: event.status, + queries: event.queries, + sources: event.sources, + updatedAt: event.updatedAt, + }); + if (!hostedSearch) return turn; + + const entries = turn.entries; + const start = segmentStartIndex(entries); + for (let index = entries.length - 1; index >= start; index -= 1) { + const entry = entries[index]; + if ( + entry?.kind === "hosted_search" && + entry.round === round && + entry.hostedSearch.id === hostedSearch.id + ) { + const next = entries.slice(); + next[index] = { + ...entry, + hostedSearch: mergeHostedSearchBlocks(entry.hostedSearch, hostedSearch), + }; + return withEntries(turn, enrichSegmentHostedSearches(next)); + } + } + + const baseId = `${turnNamespace(turn)}:hs:${round ?? 0}:${ + readString(hostedSearch.id).trim() || hashValue(hostedSearch.queries) + }`; + const occurrence = countTurnEntries( + entries, + (entry) => entry.kind === "hosted_search" && entry.id.startsWith(baseId), + ); + const next: ChatEntry[] = [ + ...entries, + buildHostedSearchEntry(hostedSearch, round, { + entryId: occurrence === 0 ? baseId : `${baseId}:${occurrence}`, + }), + ]; + return withEntries(turn, enrichSegmentHostedSearches(next)); +} + +function applyErrorEvent(turn: Turn, event: Extract): Turn { + const round = readRound(event.round); + const rawMessage = event.message ?? ""; + const message = formatLiveErrorMessage(rawMessage.trim() || "Request failed", false); + if (isAbortLikeError(message)) { + return turn; + } + const alreadyShown = turn.entries.some((entry) => { + if (entry.kind === "error" || entry.kind === "assistant") { + return entry.text.trim() === message; + } + return false; + }); + if (alreadyShown) { + return turn; + } + const text = `${segmentHasAssistantText(turn.entries) ? "\n\n" : ""}${message}`; + return withEntries(turn, [ + ...turn.entries, + { + id: `${turnNamespace(turn)}:err:${round ?? 0}:${hashText(message)}`, + kind: "assistant", + round, + text, + }, + ]); +} + +// Assistant-side stream events; user_message routing lives in the store (it +// picks the turn by client_request_id / run_id before any reducer runs). +export function applyEventToTurn(turn: Turn, event: ChatEvent): Turn { + switch (event.type) { + case "token": + return applyTokenEvent(turn, event); + case "thinking": + return applyThinkingEvent(turn, event); + case "tool_call": + case "tool_call_delta": + return applyToolCallEvent(turn, event); + case "tool_result": + return applyToolResultEvent(turn, event); + case "hosted_search": + return applyHostedSearchEvent(turn, event); + case "error": + return applyErrorEvent(turn, event); + default: + return turn; + } +} + +// Rebuilds a turn's content from a runtime snapshot (late join / reconnect +// mid-run). The rebuild targets the existing turn object: the turn key and an +// already-present user bubble keep their identity, so nothing remounts. +export function rebuildTurnFromSnapshot(turn: Turn, parsed: ChatEntry[]): Turn { + const ns = turnNamespace(turn); + const usedExistingEntries = new Set(); + const findStableEntry = (incoming: ChatEntry): ChatEntry | undefined => { + const exactSnapshotId = `${ns}:s:${incoming.id}`; + const exact = turn.entries.find( + (entry) => !usedExistingEntries.has(entry) && entry.id === exactSnapshotId, + ); + if (exact) { + return exact; + } + return turn.entries.find((entry) => { + if (usedExistingEntries.has(entry) || entry.kind !== incoming.kind) { + return false; + } + if (entry.kind === "tool_call" && incoming.kind === "tool_call") { + return Boolean(entry.toolCall.id) && entry.toolCall.id === incoming.toolCall.id; + } + if (entry.kind === "tool_result" && incoming.kind === "tool_result") { + return ( + Boolean(entry.toolResult.toolCallId) && + entry.toolResult.toolCallId === incoming.toolResult.toolCallId + ); + } + if ("round" in entry || "round" in incoming) { + const existingRound = + "round" in entry && typeof entry.round === "number" && entry.round > 0 + ? Math.floor(entry.round) + : 1; + const incomingRound = + "round" in incoming && typeof incoming.round === "number" && incoming.round > 0 + ? Math.floor(incoming.round) + : 1; + return existingRound === incomingRound; + } + return true; + }); + }; + let user = turn.user; + const entries: ChatEntry[] = []; + for (const entry of parsed) { + if (entry.kind === "user") { + if (!user) { + user = { ...entry, id: seededUserEntryId(turn.runId || turn.key) }; + } else if (entry.messageId && user.messageId !== entry.messageId) { + user = { ...user, messageId: entry.messageId }; + } + continue; + } + // Keep the delta-built identity wherever the canonical entry corresponds + // to an existing slot. This lets an authoritative correction replace the + // text/tool payload without remounting the assistant row. + const previous = findStableEntry(entry); + if (previous) { + usedExistingEntries.add(previous); + } + let merged = entry; + if (entry.kind === "tool_call" && previous?.kind === "tool_call") { + const prev = previous; + const prevProgress = toolArgsProgress( + prev.toolCall.name, + normalizeToolArguments(prev.toolCall.arguments), + ); + const snapshotProgress = toolArgsProgress( + entry.toolCall.name, + normalizeToolArguments(entry.toolCall.arguments), + ); + if ( + prevProgress !== undefined && + snapshotProgress !== undefined && + snapshotProgress < prevProgress + ) { + merged = { ...entry, toolCall: prev.toolCall, summary: prev.summary, text: prev.text }; + } + } + entries.push({ ...merged, id: previous?.id ?? `${ns}:s:${entry.id}` } as ChatEntry); + } + if (user === turn.user && entries.length === 0 && turn.entries.length === 0) { + return turn; + } + // The snapshot is complete through its as_of_seq: a rebuild from it clears + // any staleness a snapshot-less reset marked on this turn. + return { ...turn, user, entries, contentStale: false }; +} diff --git a/crates/agent-mobile/src/lib/chat/transcript/types.ts b/crates/agent-mobile/src/lib/chat/transcript/types.ts new file mode 100644 index 000000000..a3035cce7 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/transcript/types.ts @@ -0,0 +1,137 @@ +import type { RetryAttemptRecord } from "@liveagent/ui/lib/chat/retryAttempts"; +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import type { StreamRunActivity } from "@/lib/chat/stream/streamTypes"; +import type { ChatEntry, GatewayTranscriptRound } from "@/lib/chatUi"; + +export type UserChatEntry = Extract; + +// One failed-and-retried network attempt of the live run's model request, +// mirrored from the desktop's stream-retry layer over the tool_status event. +// attempt/maxAttempts are retry ordinals (1..5 of 5), not total attempts. +// 类型真源在共享包 lib/chat/retryAttempts;此处 re-export 供既有导入路径。 +export type { RetryAttemptRecord } from "@liveagent/ui/lib/chat/retryAttempts"; + +// A turn is one prompt/response exchange of the live stream: the user bubble +// (a single slot — a second user_message for the same run can only upsert it, +// never append a sibling) plus every assistant-side entry its run produced. +// Rows are emitted user-first from the same object, so "assistant content +// rendered above its own prompt" and "duplicate prompt bubble" are +// structurally unrepresentable. +export type TurnPhase = "pending" | "streaming" | "settled"; + +export type Turn = { + // Render identity, fixed at creation and never re-keyed: + // req: — this client's own submissions + // run: — foreign/seeded turns (other viewers, replays) + // local: — local error pseudo-turns + key: string; + // "" until the stream binds the turn to a run. + runId: string; + // "" for foreign turns. + clientRequestId: string; + user: UserChatEntry | null; + // Assistant-side entries: assistant | thinking | tool_call | tool_result | + // hosted_search | checkpoint | error. + entries: ChatEntry[]; + phase: TurnPhase; + // Folded turns join the identity-cached prefix of the snapshot's single + // row list; the fold only flips this flag and moves liveStartIndex — row + // keys, row objects and DOM position never change, so nothing remounts. + folded: boolean; + // The run ended during a reset gap and the replay could not rebuild the + // content: the kept streamed entries may be incomplete, so a history twin + // carrying assistant content adopts wholesale (enrichTurnFromHistory) + // instead of the usual payload-only upgrade. + contentStale?: boolean; + // Error entry appended for a falsifiable liveness verdict. A same-run + // resurrection removes this exact entry before streaming resumes. + inferredLossErrorEntryId?: string; + // Highest authoritative terminal-content revision applied for this run. + // It is independent from phase/activity: a late correction may update a + // settled turn without making it live again. + terminalContentRevision?: number; + // True once either a complete run_content_snapshot or persisted history has + // confirmed the whole settled reply. run_finished without this confirmation + // marks the turn stale and starts bounded history convergence. + contentConfirmed?: boolean; +}; + +export type TranscriptRowOrigin = "history" | "stream"; + +export type ManualCompactionResult = { + operationId: string; + status: "compacted" | "failed" | "busy" | "skipped"; + message: string; +}; + +// One rendered transcript row of the single virtualized row list; keys are +// unique by construction so an entry can never render twice. +export type TranscriptRow = + | { + key: string; + origin: TranscriptRowOrigin; + kind: "user"; + text: string; + attachments: PendingUploadedFile[]; + messageRef?: HistoryMessageRef; + timestamp?: number; + } + | { + key: string; + origin: TranscriptRowOrigin; + kind: "assistant"; + rounds: GatewayTranscriptRound[]; + turnKey?: string; + timestamp?: number; + } + | { + key: string; + origin: TranscriptRowOrigin; + kind: "checkpoint"; + content: string; + summaryId: string; + coveredMessageCount: number; + generatedBy: { + providerId: string; + model: string; + promptVersion?: string; + }; + contextUsageTokens?: number; + timestamp?: number; + } + | { key: string; origin: TranscriptRowOrigin; kind: "error"; text: string }; + +export type HistoryApplyMode = "replace" | "enrich"; + +export type TranscriptSnapshot = { + // The whole transcript as one row list, rendered by a single virtualized + // container. The folded prefix (history + folded turns) is identity-stable + // across streaming commits — memoized rows bail while a reply streams — + // and folding only moves liveStartIndex, so row objects, keys and DOM all + // carry over untouched. + rows: readonly TranscriptRow[]; + // Index of the first unfolded-turn row; -1 when everything is folded. Rows + // at or after this index are force-mounted by the virtualizer so a + // streaming reply never unmounts mid-run. + liveStartIndex: number; + // Key of the turn whose run is streaming (live structural state + caret + // attribution in the renderer). + activeTurnKey: string | null; + // Total entry count (history entries + turn entries + user slots) — drives + // "does this conversation hold content" checks. + entryCount: number; + activeRun: StreamRunActivity | null; + toolStatus: string | null; + toolStatusIsCompaction: boolean; + // Live run's stream-retry history (cleared at run boundaries and whenever + // the desktop starts a fresh network attempt). + retryAttempts: readonly RetryAttemptRecord[]; + manualCompactionResult: ManualCompactionResult | null; + // At least one settled streamed turn may be incomplete and should keep + // retrying the quiet history enrich while the conversation is idle. + needsHistoryRefresh: boolean; + // Bumped whenever turns fold into the virtualized region. + foldRevision: number; + revision: number; +}; diff --git a/crates/agent-mobile/src/lib/chat/uiMessages.ts b/crates/agent-mobile/src/lib/chat/uiMessages.ts new file mode 100644 index 000000000..ac2420959 --- /dev/null +++ b/crates/agent-mobile/src/lib/chat/uiMessages.ts @@ -0,0 +1 @@ +export * from "@liveagent/ui/lib/chat/uiMessages"; diff --git a/crates/agent-mobile/src/lib/chatUi.ts b/crates/agent-mobile/src/lib/chatUi.ts new file mode 100644 index 000000000..51e9d83d1 --- /dev/null +++ b/crates/agent-mobile/src/lib/chatUi.ts @@ -0,0 +1,936 @@ +import type { SharedChatEntry } from "@liveagent/ui/contracts/chatEntry"; +import { positiveTokenCount } from "@liveagent/ui/lib/chat/contextUsage"; +import { createUuid } from "@liveagent/ui/lib/shared/id"; + +export { hashText } from "@liveagent/ui/lib/shared/hash"; + +import { + type HostedSearchBlock, + normalizeHostedSearchBlock, +} from "@liveagent/ui/lib/chat/hostedSearch"; +import { + getUserMessageAttachments, + getUserMessageDisplayText, + type PendingUploadedFile, +} from "@liveagent/ui/lib/chat/uploadedFiles"; +import { hashText } from "@liveagent/ui/lib/shared/hash"; +import type { Message, ToolCall, ToolResultMessage, Usage } from "@/lib/agentTypes"; +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import { summarizeToolCall as summarizeDesktopToolCall, type UiRound } from "@/lib/chat/uiMessages"; + +import type { ChatCheckpointPayload, ChatEvent, ConversationSummary } from "./gatewayTypes"; + +export type AssistantMeta = NonNullable; + +export type GatewayTranscriptRound = UiRound & { + key: string; + runningToolCallIds: string[]; + thinkingOpen?: boolean; +}; + +type SharedGatewayChatEntry = SharedChatEntry< + ToolCall, + ToolResultMessage, + AssistantMeta, + { messageId?: string; messageRef?: HistoryMessageRef; timestamp?: number }, + { timestamp?: number } +>; + +export type ChatEntry = + | SharedGatewayChatEntry + | { + id: string; + kind: "checkpoint"; + content: string; + summaryId: string; + coveredMessageCount: number; + generatedBy: { + providerId: string; + model: string; + promptVersion?: string; + }; + contextUsageTokens?: number; + timestamp?: number; + }; + +type StoredMessage = { + role?: unknown; + id?: unknown; + content?: unknown; + details?: unknown; + toolCallId?: unknown; + toolName?: unknown; + isError?: unknown; + provider?: unknown; + model?: unknown; + api?: unknown; + stopReason?: unknown; + usage?: unknown; + timestamp?: unknown; + summaryMeta?: unknown; + liveAgentHistoryRef?: unknown; + liveAgentContextUsage?: unknown; +}; + +function readMessageTimestamp(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +export type ToolCallLike = { + id?: unknown; + name?: unknown; + toolCallId?: unknown; + toolCallID?: unknown; + tool_call_id?: unknown; + call_id?: unknown; + toolName?: unknown; + tool_name?: unknown; + arguments?: unknown; + args?: unknown; + input?: unknown; + parameters?: unknown; + toolCall?: unknown; + payload?: unknown; + data?: unknown; +}; + +type NormalizedAssistantBlock = + | { type: "text"; text: string } + | { type: "thinking"; text: string } + | { type: "toolCall"; toolCall: ToolCallLike } + | { type: "hostedSearch"; hostedSearch: HostedSearchBlock }; + +type UploadedFilesUserMessage = Pick & Record; + +const LIVE_UPLOADED_FILE_KINDS = new Set([ + "text", + "image", + "pdf", + "notebook", + "word", + "spreadsheet", + "archive", +]); + +function randomId(prefix: string) { + return `${prefix}-${createUuid()}`; +} + +export function hashValue(value: unknown) { + return hashText(safeStringify(value)); +} + +const DSML_TAG_PREFIX = String.raw`(?:\uFF5C{2}|\|{2})\s*DSML\s*(?:\uFF5C{2}|\|{2})`; +const DSML_TOOL_CALL_DISPLAY_PATTERN = new RegExp( + String.raw`<\s*${DSML_TAG_PREFIX}\s*tool_calls\s*>[\s\S]*?(?:<\/\s*${DSML_TAG_PREFIX}\s*tool_calls\s*>|$)`, + "gi", +); + +export function stripRecoveredToolCallMarkup(value: string) { + if ( + !value.includes("") && + !(value.includes("DSML") && value.includes("tool_calls")) + ) { + return value; + } + return value + .replace(/[\s\S]*?(?:<\/seed:tool_call>|$)/gi, "") + .replace(DSML_TOOL_CALL_DISPLAY_PATTERN, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function readString(value: unknown) { + return typeof value === "string" ? value : ""; +} + +function readNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizeLiveUploadedFile(value: unknown): PendingUploadedFile | null { + const record = asNonArrayRecord(value); + const relativePath = readString(record.relativePath ?? record.relative_path).trim(); + const fileName = readString(record.fileName ?? record.file_name).trim() || relativePath; + const kind = readString(record.kind).trim(); + const sizeBytes = readNumber(record.sizeBytes ?? record.size_bytes) ?? 0; + if (!relativePath || !fileName || !LIVE_UPLOADED_FILE_KINDS.has(kind)) { + return null; + } + const absolutePath = readString(record.absolutePath ?? record.absolute_path).trim(); + const file: PendingUploadedFile = { + relativePath, + absolutePath: absolutePath || undefined, + fileName, + kind: kind as PendingUploadedFile["kind"], + sizeBytes: Math.max(0, Math.floor(sizeBytes)), + }; + const displayMode = readString(record.displayMode ?? record.display_mode).trim(); + if (displayMode === "largePaste") { + file.displayMode = "largePaste"; + } + const displayLabel = readString(record.displayLabel ?? record.display_label).trim(); + if (displayLabel) { + file.displayLabel = displayLabel; + } + const displayCharCount = readNumber(record.displayCharCount ?? record.display_char_count); + if (typeof displayCharCount === "number") { + file.displayCharCount = Math.max(0, Math.floor(displayCharCount)); + } + const displayLineCount = readNumber(record.displayLineCount ?? record.display_line_count); + if (typeof displayLineCount === "number") { + file.displayLineCount = Math.max(0, Math.floor(displayLineCount)); + } + return file; +} + +export function normalizeLiveUploadedFiles(value: unknown): PendingUploadedFile[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => normalizeLiveUploadedFile(item)) + .filter((file): file is PendingUploadedFile => file !== null); +} + +// Parses a persisted message identity from either wire casing: camelCase for +// history's embedded liveAgentHistoryRef (desktop Rust producer), snake_case +// for stream events' message_ref/base_message_ref (buildHistoryMessageRefPayload +// mirrors). All six fields are required — a partial ref cannot anchor a rebase. +export function readHistoryMessageRef(value: unknown): HistoryMessageRef | undefined { + const record = asRecord(value); + const segmentIndex = readNumber(record.segmentIndex ?? record.segment_index); + const messageIndex = readNumber(record.messageIndex ?? record.message_index); + const segmentId = readString(record.segmentId ?? record.segment_id)?.trim(); + const messageId = readString(record.messageId ?? record.message_id)?.trim(); + const role = readString(record.role)?.trim(); + const contentHash = readString(record.contentHash ?? record.content_hash)?.trim(); + if ( + typeof segmentIndex !== "number" || + typeof messageIndex !== "number" || + segmentIndex < 0 || + messageIndex < 0 || + !segmentId || + !messageId || + !role || + !contentHash + ) { + return undefined; + } + return { + segmentIndex: Math.floor(segmentIndex), + messageIndex: Math.floor(messageIndex), + segmentId, + messageId, + role, + contentHash, + }; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function asNonArrayRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function recordHasEntries(value: Record) { + return Object.keys(value).length > 0; +} + +function cloneJsonLikeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonLikeValue(item)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, nestedValue] of Object.entries(value as Record)) { + out[key] = cloneJsonLikeValue(nestedValue); + } + return out; + } + return value; +} + +function cloneRecord(value: Record): Record { + return cloneJsonLikeValue(value) as Record; +} + +function parseJsonRecord(value: unknown): Record { + if (typeof value !== "string" || value.trim() === "") { + return {}; + } + try { + return asNonArrayRecord(JSON.parse(value)); + } catch { + return {}; + } +} + +export function normalizeToolArguments(...candidates: unknown[]): Record { + for (const candidate of candidates) { + const direct = asNonArrayRecord(candidate); + if (recordHasEntries(direct)) { + return cloneRecord(direct); + } + const parsed = parseJsonRecord(candidate); + if (recordHasEntries(parsed)) { + return cloneRecord(parsed); + } + } + return {}; +} + +export function normalizeToolCallLike(input: ToolCallLike): ToolCallLike { + const record = asNonArrayRecord(input); + const payloadRecord = asNonArrayRecord(record.payload); + const dataObjectRecord = asNonArrayRecord(record.data); + const dataJsonRecord = parseJsonRecord(record.data); + const dataRecord = recordHasEntries(dataObjectRecord) ? dataObjectRecord : dataJsonRecord; + const nestedToolCall = asNonArrayRecord( + record.toolCall ?? payloadRecord.toolCall ?? dataRecord.toolCall, + ); + const source = recordHasEntries(nestedToolCall) + ? nestedToolCall + : recordHasEntries(payloadRecord) + ? payloadRecord + : recordHasEntries(dataRecord) + ? dataRecord + : record; + return { + id: + source.id ?? + source.toolCallId ?? + source.toolCallID ?? + source.tool_call_id ?? + source.call_id ?? + record.id, + name: source.name ?? source.toolName ?? source.tool_name ?? record.name, + arguments: normalizeToolArguments( + source.arguments, + source.args, + source.input, + source.parameters, + payloadRecord.arguments, + payloadRecord.args, + payloadRecord.input, + payloadRecord.parameters, + dataRecord.arguments, + dataRecord.args, + dataRecord.input, + dataRecord.parameters, + record.arguments, + record.args, + record.input, + record.parameters, + ), + }; +} + +function asUploadedFilesUserMessage(message: StoredMessage): UploadedFilesUserMessage { + return { + ...asRecord(message), + role: "user", + content: message.content as Message["content"], + }; +} + +export function safeStringify(value: unknown) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function getUsageTotalTokens(usage: unknown) { + const record = asRecord(usage); + const totalTokens = record.totalTokens; + if (typeof totalTokens === "number") return totalTokens; + const snakeCaseTotal = record.total_tokens; + return typeof snakeCaseTotal === "number" ? snakeCaseTotal : undefined; +} + +export function buildAssistantMeta(params: { + provider?: unknown; + model?: unknown; + api?: unknown; + stopReason?: unknown; + usage?: unknown; + contextUsageTokens?: unknown; + contextRelevant?: unknown; +}) { + const usage = + params.usage && typeof params.usage === "object" ? (params.usage as Usage) : undefined; + + // 增量构建:只 set 已定义的字段,绝不物化出 own-property undefined 键。后者在 + // `{ ...target.meta, ...meta }` 合并时会用 undefined 覆盖此前事件送达的 + // contextUsageTokens/usageTotalTokens 等锚点,导致用量环塌值(issue #359 缺陷 #2)。 + const meta: AssistantMeta = {}; + const provider = readString(params.provider) || undefined; + if (provider !== undefined) meta.provider = provider; + const model = readString(params.model) || undefined; + if (model !== undefined) meta.model = model; + const api = readString(params.api) || undefined; + if (api !== undefined) meta.api = api; + const stopReason = readString(params.stopReason) || undefined; + if (stopReason !== undefined) meta.stopReason = stopReason; + if (usage !== undefined) meta.usage = usage; + const usageTotalTokens = getUsageTotalTokens(params.usage); + if (usageTotalTokens !== undefined) meta.usageTotalTokens = usageTotalTokens; + const contextUsageTokens = positiveTokenCount(params.contextUsageTokens); + if (contextUsageTokens !== undefined) meta.contextUsageTokens = contextUsageTokens; + if (typeof params.contextRelevant === "boolean") { + meta.contextRelevant = params.contextRelevant; + } + + return Object.keys(meta).length > 0 ? meta : undefined; +} + +export function normalizeCheckpointEntry(params: { + id?: unknown; + content?: unknown; + timestamp?: unknown; + summaryMeta?: unknown; + checkpoint?: ChatCheckpointPayload; + fallbackId?: string; +}) { + const summaryMetaRecord = asRecord(params.summaryMeta); + const generatedByRecord = asRecord( + params.checkpoint?.generatedBy ?? summaryMetaRecord.generatedBy, + ); + const content = readString(params.content).trim(); + if (content === "") { + return null; + } + + const summaryId = + readString(params.id).trim() || + readString(params.checkpoint?.summaryId).trim() || + params.fallbackId || + randomId("checkpoint"); + const coveredMessageCountCandidate = + typeof params.checkpoint?.coveredMessageCount === "number" + ? params.checkpoint.coveredMessageCount + : summaryMetaRecord.coveredMessageCount; + const coveredMessageCount = positiveTokenCount(coveredMessageCountCandidate) ?? 0; + const providerId = readString(generatedByRecord.providerId).trim() || "liveagent"; + const model = readString(generatedByRecord.model).trim() || "summary"; + const promptVersion = readString(generatedByRecord.promptVersion).trim() || undefined; + const timestamp = + readNumber(params.checkpoint?.timestamp) ?? readNumber(params.timestamp) ?? Date.now(); + const summaryStatsRecord = asRecord(summaryMetaRecord.stats); + const contextUsageTokens = positiveTokenCount( + params.checkpoint?.contextUsageTokens ?? summaryStatsRecord.contextTokensAfter, + ); + + return { + id: `checkpoint-${summaryId}`, + kind: "checkpoint" as const, + content, + summaryId, + coveredMessageCount, + generatedBy: { + providerId, + model, + promptVersion, + }, + contextUsageTokens, + timestamp, + }; +} + +export function isCheckpointTokenEvent(event: Extract) { + return Boolean( + event.checkpoint || + event.api === "liveagent-compaction" || + (event.provider === "liveagent" && event.model === "summary"), + ); +} + +function normalizeToolCall(toolCall: ToolCallLike, fallbackId: string): ToolCall { + const normalized = normalizeToolCallLike(toolCall); + const id = readString(normalized.id).trim() || fallbackId; + const name = readString(normalized.name).trim() || "Tool"; + return { + type: "toolCall", + id, + name, + arguments: normalizeToolArguments(normalized.arguments), + } as ToolCall; +} + +function normalizeToolResultContentBlock(block: unknown): ToolResultMessage["content"][number][] { + if (typeof block === "string") { + return block === "" ? [] : ([{ type: "text", text: block }] as ToolResultMessage["content"]); + } + + const record = asRecord(block); + const type = readString(record.type); + if (type === "text") { + return [{ type: "text", text: readString(record.text) }] as ToolResultMessage["content"]; + } + if (type === "image" && typeof record.mimeType === "string" && typeof record.data === "string") { + return [ + { + type: "image", + mimeType: record.mimeType, + data: record.data, + }, + ] as ToolResultMessage["content"]; + } + + if (Object.keys(record).length === 0) { + return []; + } + + return [{ type: "text", text: safeStringify(record) }] as ToolResultMessage["content"]; +} + +function normalizeToolResultContent(content: unknown): ToolResultMessage["content"] { + if (Array.isArray(content)) { + return content.flatMap((block) => normalizeToolResultContentBlock(block)); + } + return normalizeToolResultContentBlock(content) as ToolResultMessage["content"]; +} + +function buildToolResult(params: { + toolCallId?: unknown; + toolName?: unknown; + content?: unknown; + details?: unknown; + isError?: unknown; + timestamp?: unknown; + fallbackToolCallId?: string; +}) { + const toolCallId = + readString(params.toolCallId).trim() || params.fallbackToolCallId || randomId("tool-result"); + const toolName = readString(params.toolName).trim() || "Tool"; + const timestamp = readNumber(params.timestamp) ?? Date.now(); + + return { + role: "toolResult", + toolCallId, + toolName, + content: normalizeToolResultContent(params.content), + details: params.details, + isError: Boolean(params.isError), + timestamp, + } as ToolResultMessage; +} + +function summarizeToolCall(toolCall: ToolCall) { + return summarizeDesktopToolCall(toolCall); +} + +function getTextFromContent(content: unknown) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + let text = ""; + for (const block of content) { + const record = asRecord(block); + if (readString(record.type) === "text") { + text += readString(record.text); + } + } + return text; +} + +export function getToolResultText(content: unknown) { + const directText = getTextFromContent(content); + if (directText.trim() !== "") return directText; + + if (typeof content === "string") { + return content; + } + + if (content === undefined) { + return ""; + } + + return safeStringify(content); +} + +function normalizeAssistantBlocks(content: unknown): NormalizedAssistantBlock[] { + if (typeof content === "string") { + const text = stripRecoveredToolCallMarkup(content); + return text.trim() ? [{ type: "text", text }] : []; + } + if (!Array.isArray(content)) { + return []; + } + + const blocks: NormalizedAssistantBlock[] = []; + for (const block of content) { + const record = asRecord(block); + const type = readString(record.type); + if (type === "text") { + const text = stripRecoveredToolCallMarkup(readString(record.text)); + if (text !== "") { + blocks.push({ type: "text", text }); + } + continue; + } + if (type === "thinking") { + const text = stripRecoveredToolCallMarkup(readString(record.thinking)); + if (text !== "") { + blocks.push({ type: "thinking", text }); + } + continue; + } + if (type === "toolCall" || type === "tool_use") { + blocks.push({ + type: "toolCall", + toolCall: normalizeToolCallLike(record), + }); + continue; + } + const hostedSearch = normalizeHostedSearchBlock(record); + if (hostedSearch) { + blocks.push({ + type: "hostedSearch", + hostedSearch, + }); + } + } + return blocks; +} + +export function buildToolCallEntry( + toolCall: ToolCallLike, + round?: number, + options?: { + entryId?: string; + fallbackToolCallId?: string; + }, +): ChatEntry { + const normalizedToolCall = normalizeToolCall( + toolCall, + options?.fallbackToolCallId ?? randomId("tool-call"), + ); + return { + id: options?.entryId ?? randomId("tool-call"), + kind: "tool_call", + round, + toolCall: normalizedToolCall, + summary: summarizeToolCall(normalizedToolCall), + text: safeStringify(normalizedToolCall.arguments), + }; +} + +export function buildToolResultEntry( + message: StoredMessage, + round?: number, + options?: { + entryId?: string; + fallbackToolCallId?: string; + }, +): ChatEntry { + const toolResult = buildToolResult({ + toolCallId: message.toolCallId, + toolName: message.toolName, + content: message.content, + details: message.details, + isError: message.isError, + timestamp: message.timestamp, + fallbackToolCallId: options?.fallbackToolCallId, + }); + return { + id: options?.entryId ?? randomId("tool-result"), + kind: "tool_result", + round, + toolResult, + summary: toolResult.toolName ? `${toolResult.toolName} 执行结果` : "工具执行结果", + text: getToolResultText(message.content), + }; +} + +export function buildHostedSearchEntry( + hostedSearch: HostedSearchBlock, + round?: number, + options?: { entryId?: string }, +): ChatEntry { + return { + id: options?.entryId ?? randomId("hosted-search"), + kind: "hosted_search", + round, + hostedSearch, + }; +} + +export function formatLiveErrorMessage(message: string, prefix: boolean) { + if (!prefix || message === "Request failed") { + return message; + } + return message.startsWith("Request failed:") || message.startsWith("Request failed:") + ? message + : `Request failed: ${message}`; +} + +export function parseHistoryMessagesJson(raw: string): ChatEntry[] { + if (raw.trim() === "") return []; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + const text = `历史消息解析失败:${message}`; + return [{ id: `history-error:${hashText(text)}`, kind: "error", text }]; + } + + if (!Array.isArray(parsed)) { + const text = "历史消息载荷不是数组,无法渲染。"; + return [{ id: `history-error:${hashText(text)}`, kind: "error", text }]; + } + + const entries: ChatEntry[] = []; + const usedUserIds = new Map(); + let currentRound = 0; + // Turn anchor: the current user entry's id ("h:^" for a window that starts + // mid-turn) plus a per-turn block counter for every non-user entry. + let turnKey = "ht:^"; + let turnEntrySeq = 0; + const nextEntryId = () => `${turnKey}>${turnEntrySeq++}`; + + for (const item of parsed) { + const message = asRecord(item) as StoredMessage; + const role = readString(message.role); + + if (role === "user") { + currentRound = 0; + const userRecord = asUploadedFilesUserMessage(message); + const text = getUserMessageDisplayText(userRecord); + const attachments = getUserMessageAttachments(userRecord); + const messageRef = readHistoryMessageRef(userRecord.liveAgentHistoryRef); + if (text.trim() || attachments.length > 0) { + const baseId = messageRef ? `hu:${messageRef.messageId}` : `hu:~${hashText(text)}`; + const occurrence = usedUserIds.get(baseId) ?? 0; + usedUserIds.set(baseId, occurrence + 1); + const id = occurrence === 0 ? baseId : `${baseId}:${occurrence}`; + turnKey = `ht:${id}`; + turnEntrySeq = 0; + entries.push({ + id, + kind: "user", + text, + attachments, + messageId: messageRef?.messageId, + messageRef, + timestamp: readMessageTimestamp(message.timestamp), + }); + } + continue; + } + + if (role === "summary") { + const checkpoint = normalizeCheckpointEntry({ + id: message.id, + content: message.content, + timestamp: message.timestamp, + summaryMeta: message.summaryMeta, + fallbackId: nextEntryId(), + }); + if (checkpoint) { + entries.push(checkpoint); + } + continue; + } + + if (role === "assistant") { + currentRound += 1; + const round = currentRound; + const messageTimestamp = readMessageTimestamp(message.timestamp); + const blocks = normalizeAssistantBlocks(message.content); + // 重建 meta 不带 contextRelevant 是有意为之(issue #359 缺陷 #8):桌面端只对 + // render-only 轮次(记忆抽取等)标 contextRelevant:false,而这类轮次仅经 + // appendRenderOnlyMessagesToConversation 进入 UI transcript.items 供展示, + // 从不写入持久化的 segment.messages(见 chatHistory.ts writeConversationRuntime)。 + // 因此历史 JSON 里根本不存在 render-only 轮次,也就没有可辨识标记可供重建; + // 用量环倒扫(deriveContextUsageTokens)不会误锚定在抽取请求的小 usage 上。 + // 若将来 render-only 轮次开始入历史 JSON,须在此按其标记重建 meta.contextRelevant。 + const meta = buildAssistantMeta({ + provider: message.provider, + model: message.model, + api: message.api, + stopReason: message.stopReason, + usage: message.usage, + contextUsageTokens: asRecord(message.liveAgentContextUsage).totalTokens, + }); + let textBuffer = ""; + let metaEmitted = false; + + const flushText = () => { + if (textBuffer === "" && (!meta || metaEmitted)) return; + entries.push({ + id: nextEntryId(), + kind: "assistant", + text: textBuffer, + round, + meta: metaEmitted ? undefined : meta, + timestamp: messageTimestamp, + }); + textBuffer = ""; + if (meta) { + metaEmitted = true; + } + }; + + for (const block of blocks) { + if (block.type === "text") { + textBuffer += block.text; + continue; + } + + flushText(); + + if (block.type === "thinking" && block.text.trim()) { + entries.push({ + id: nextEntryId(), + kind: "thinking", + round, + text: block.text, + }); + } + + if (block.type === "toolCall") { + const entryId = nextEntryId(); + entries.push( + buildToolCallEntry(block.toolCall, round, { + entryId, + fallbackToolCallId: entryId, + }), + ); + } + + if (block.type === "hostedSearch") { + entries.push( + buildHostedSearchEntry(block.hostedSearch, round, { entryId: nextEntryId() }), + ); + } + } + + flushText(); + continue; + } + + if (role === "toolResult") { + const entryId = nextEntryId(); + entries.push( + buildToolResultEntry(message, currentRound || 1, { + entryId, + fallbackToolCallId: entryId, + }), + ); + } + } + + return entries; +} + +export function isMatchingToolCallEntry( + entry: ChatEntry, + params: { + id?: unknown; + name?: unknown; + arguments?: unknown; + round?: number; + }, +) { + if (entry.kind !== "tool_call") { + return false; + } + if (params.round !== undefined && entry.round !== params.round) { + return false; + } + + const toolCallId = readString(params.id).trim(); + if (toolCallId !== "") { + return entry.toolCall.id === toolCallId; + } + + const toolName = readString(params.name).trim(); + if (toolName === "" || entry.toolCall.name !== toolName) { + return false; + } + + return safeStringify(entry.toolCall.arguments) === safeStringify(asRecord(params.arguments)); +} + +export function isMatchingToolResultEntry( + entry: ChatEntry, + params: { + toolCallId?: unknown; + toolName?: unknown; + content?: unknown; + isError?: unknown; + round?: number; + }, +) { + if (entry.kind !== "tool_result") { + return false; + } + if (params.round !== undefined && entry.round !== params.round) { + return false; + } + + const toolCallId = readString(params.toolCallId).trim(); + if (toolCallId !== "") { + return entry.toolResult.toolCallId === toolCallId; + } + + const toolName = readString(params.toolName).trim(); + if (toolName === "" || entry.toolResult.toolName !== toolName) { + return false; + } + if (Boolean(entry.toolResult.isError) !== Boolean(params.isError)) { + return false; + } + + return getToolResultText(entry.toolResult.content) === getToolResultText(params.content); +} + +export function formatConversationTitle( + conversation?: Pick | null, + fallbackId?: string, +) { + const title = conversation?.title?.trim(); + if (title) return title; + if (fallbackId?.trim()) return `会话 ${fallbackId.slice(0, 8)}`; + return "新对话"; +} + +export function resolveConversationBrowserTitle(params: { + conversation?: Pick | null; + conversationId?: string | null; + projectName?: string | null; + isLocalDraftConversation?: boolean; + newConversationTitle: string; +}) { + const conversationId = params.conversationId?.trim() ?? ""; + const newConversationTitle = params.newConversationTitle.trim() || "LiveAgent"; + if (!conversationId || params.isLocalDraftConversation) { + return newConversationTitle; + } + if (params.conversation) { + return formatConversationTitle(params.conversation, conversationId); + } + const projectName = params.projectName?.trim() ?? ""; + return projectName || formatConversationTitle(null, conversationId); +} + +export function buildOptimisticConversationTitle(message: string) { + const firstParagraph = message + .split(/\r?\n\s*\r?\n/) + .map((paragraph) => paragraph.replace(/\s+/g, " ").trim()) + .find((paragraph) => paragraph !== ""); + if (!firstParagraph) { + return "新对话"; + } + return Array.from(firstParagraph).slice(0, 10).join(""); +} diff --git a/crates/agent-mobile/src/lib/clipboardFiles.ts b/crates/agent-mobile/src/lib/clipboardFiles.ts new file mode 100644 index 000000000..4b605de00 --- /dev/null +++ b/crates/agent-mobile/src/lib/clipboardFiles.ts @@ -0,0 +1,120 @@ +function clipboardFileExtension(mimeType: string) { + switch (mimeType.trim().toLowerCase()) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/gif": + return "gif"; + case "image/webp": + return "webp"; + case "image/bmp": + return "bmp"; + case "image/tiff": + return "tiff"; + case "application/pdf": + return "pdf"; + case "text/plain": + return "txt"; + case "text/markdown": + return "md"; + case "application/json": + return "json"; + default: + return "bin"; + } +} + +function isClipboardUploadMimeType(mimeType: string) { + const normalized = mimeType.trim().toLowerCase(); + if (normalized.startsWith("image/")) return true; + return ( + normalized === "application/pdf" || + normalized === "text/markdown" || + normalized === "application/json" + ); +} + +function normalizeClipboardFile(file: File, index: number) { + if (file.name.trim()) { + return file; + } + const extension = clipboardFileExtension(file.type); + const fallbackName = `clipboard-file-${index + 1}.${extension}`; + return new File([file], fallbackName, { + type: file.type || "application/octet-stream", + lastModified: file.lastModified || Date.now(), + }); +} + +function clipboardFileKey(file: File) { + return [file.name.trim() || "__clipboard_file__", file.type, file.size, file.lastModified].join( + "\u0000", + ); +} + +function appendUniqueClipboardFile(files: File[], seen: Set, file: File | null) { + if (!file) return; + const key = clipboardFileKey(file); + if (seen.has(key)) return; + seen.add(key); + files.push(normalizeClipboardFile(file, files.length)); +} + +export function extractClipboardFiles(data: DataTransfer | null | undefined) { + const files: File[] = []; + const seen = new Set(); + if (!data) return files; + + const clipboardFiles = Array.from(data.files ?? []); + if (clipboardFiles.length > 0) { + for (const file of clipboardFiles) { + appendUniqueClipboardFile(files, seen, file); + } + return files; + } + + for (const item of Array.from(data.items ?? [])) { + if (item.kind !== "file") continue; + appendUniqueClipboardFile(files, seen, item.getAsFile()); + } + + return files; +} + +export function clipboardHasFileSignal(data: DataTransfer | null | undefined) { + if (!data) return false; + if (Array.from(data.items ?? []).some((item) => item.kind === "file")) { + return true; + } + return Array.from(data.types ?? []).some((type) => { + const normalized = type.trim().toLowerCase(); + return normalized === "files" || isClipboardUploadMimeType(normalized); + }); +} + +export async function readClipboardFiles() { + const clipboard = navigator.clipboard; + if (!clipboard || typeof clipboard.read !== "function") { + return []; + } + + const files: File[] = []; + const seen = new Set(); + const items = await clipboard.read(); + for (const item of items) { + const type = item.types.find(isClipboardUploadMimeType); + if (!type) continue; + const blob = await item.getType(type); + const file = new File( + [blob], + `clipboard-file-${files.length + 1}.${clipboardFileExtension(type)}`, + { + type, + lastModified: Date.now(), + }, + ); + appendUniqueClipboardFile(files, seen, file); + } + return files; +} diff --git a/crates/agent-mobile/src/lib/gatewayAuth.ts b/crates/agent-mobile/src/lib/gatewayAuth.ts new file mode 100644 index 000000000..594e2f11d --- /dev/null +++ b/crates/agent-mobile/src/lib/gatewayAuth.ts @@ -0,0 +1,59 @@ +import { getGatewayOrigin } from "@/lib/gatewayOrigin"; + +function readUnauthorizedErrorMessage(errorText: string) { + return errorText === "unauthorized" ? "Access Token 错误,请检查后重试。" : errorText; +} + +async function readFetchError(response: Response, fallback: string) { + const raw = (await response.text()).trim(); + if (!raw) { + return fallback; + } + + try { + const payload = JSON.parse(raw) as { error?: unknown; message?: unknown }; + const errorText = + typeof payload.error === "string" + ? payload.error.trim() + : typeof payload.message === "string" + ? payload.message.trim() + : ""; + return readUnauthorizedErrorMessage(errorText || raw); + } catch { + return readUnauthorizedErrorMessage(raw); + } +} + +export function normalizeGatewayAccessToken(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + const match = trimmed.match(/^bearer\s+(.+)$/i); + if (!match) { + return trimmed; + } + + return match[1]?.trim() ?? ""; +} + +export async function verifyGatewayAccessToken(input: string) { + const token = normalizeGatewayAccessToken(input); + if (!token) { + throw new Error("请输入 Access Token。"); + } + + const response = await fetch(`${getGatewayOrigin()}/api/status`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(await readFetchError(response, "Access Token 验证失败。")); + } + + return token; +} diff --git a/crates/agent-mobile/src/lib/gatewayOrigin.ts b/crates/agent-mobile/src/lib/gatewayOrigin.ts new file mode 100644 index 000000000..776a1ea55 --- /dev/null +++ b/crates/agent-mobile/src/lib/gatewayOrigin.ts @@ -0,0 +1,53 @@ +import { clearGatewayUrl, loadGatewayUrl, saveGatewayUrl } from "./storage"; + +// agent-mobile is a Tauri shell: window.location.origin is the app's internal +// asset origin (e.g. tauri://localhost), never the remote Gateway. The +// configured origin below is the single source of truth for every Gateway +// request; window.location.origin is only a fallback for same-origin/browser +// testing where no origin has been configured yet. +function normalizeGatewayOrigin(input: string): string { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("请输入 Gateway 地址。"); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Gateway 地址无效,请输入完整的 http(s) 地址。"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Gateway 地址必须以 http:// 或 https:// 开头。"); + } + + return parsed.origin; +} + +let configuredOrigin = (() => { + try { + return loadGatewayUrl().trim() ? normalizeGatewayOrigin(loadGatewayUrl()) : ""; + } catch { + return ""; + } +})(); + +export function getGatewayOrigin(): string { + if (configuredOrigin) { + return configuredOrigin; + } + return typeof window !== "undefined" ? window.location.origin : ""; +} + +export function setGatewayOrigin(input: string): string { + const normalized = normalizeGatewayOrigin(input); + configuredOrigin = normalized; + saveGatewayUrl(normalized); + return normalized; +} + +export function clearGatewayOrigin(): void { + configuredOrigin = ""; + clearGatewayUrl(); +} diff --git a/crates/agent-mobile/src/lib/gatewaySocket.ts b/crates/agent-mobile/src/lib/gatewaySocket.ts new file mode 100644 index 000000000..c3be8919d --- /dev/null +++ b/crates/agent-mobile/src/lib/gatewaySocket.ts @@ -0,0 +1,3612 @@ +import { buildHistoryMessageRefPayload } from "@liveagent/ui/lib/chat/historyMessageRef"; +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import type { + GatewaySettingsSyncPayload, + GatewaySettingsSyncUpdatePayload, +} from "@liveagent/ui/lib/settings/sync"; +import { + normalizeSftpActionResponse, + normalizeSftpListResponse, + normalizeSftpStatResponse, + normalizeSftpTransferEvent, + normalizeSftpTransferResponse, + type RawSftpResponse, + type RawSftpTransferEvent, +} from "@liveagent/ui/lib/sftp/normalization"; +import type { + SftpActionResponse, + SftpListResponse, + SftpStatResponse, + SftpTransferEvent, + SftpTransferResponse, +} from "@liveagent/ui/lib/sftp/types"; +import { createUuid } from "@liveagent/ui/lib/shared/id"; +import { + buildTerminalCreatePayload, + buildTerminalSshCreatePayload, + buildTerminalSshPromptAnswerPayload, + normalizeSshTerminalTabsSnapshot, + normalizeTerminalByteContainer, + normalizeTerminalEvent, + normalizeTerminalSession, + normalizeTerminalShellOptions, + normalizeTerminalSnapshot, + normalizeTerminalSshCreateResult, + normalizeTerminalSshLatency, + type RawSshTerminalTabsSnapshot, + type RawTerminalSession, + type RawTerminalShellOptionsResponse, + type RawTerminalSnapshot, + type RawTerminalSshLatency, + type RawTerminalEvent as SharedRawTerminalEvent, +} from "@liveagent/ui/lib/terminal/normalization"; +import type { + RawSshLocalForwardAction, + RawSshLocalForwardEvent, + RawSshLocalForwardSnapshot, + SshLocalForwardAction, + SshLocalForwardEvent, + SshLocalForwardSnapshot, +} from "@liveagent/ui/lib/terminal/sshLocalForwardTypes"; +import { + normalizeSshLocalForwardAction, + normalizeSshLocalForwardEvent, + normalizeSshLocalForwardSnapshot, +} from "@liveagent/ui/lib/terminal/sshLocalForwardTypes"; +import type { + SshTerminalTabKind, + SshTerminalTabsSnapshot, + TerminalEvent, + TerminalSession, + TerminalShellOptions, + TerminalSnapshot, + TerminalSshCreateResult, + TerminalSshLatency, + TerminalStreamClient, +} from "@liveagent/ui/lib/terminal/types"; +import type { + TunnelCreateInput, + TunnelHealth, + TunnelStateSnapshot, + TunnelStatus, + TunnelUpdateInput, +} from "@liveagent/ui/lib/tunnels/constants"; +import type { + WorkspaceActivity, + WorkspaceActivityEventPayload, +} from "@liveagent/ui/lib/workspace-activity/types"; +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import { ConversationStreamClient } from "@/lib/chat/stream/conversationStreamClient"; +import type { + ChatCommandAccepted, + ChatCommandUpdate, + ConversationActivityEvent, + ConversationStreamHandlers, +} from "@/lib/chat/stream/streamTypes"; +import { normalizeActivityEvent, normalizeCommandUpdate } from "@/lib/chat/stream/streamTypes"; +import { BrowserGatewayTerminalStreamClient } from "@/lib/terminal/gatewayTerminalStreamClient"; +import { getGatewayOrigin } from "./gatewayOrigin"; +import type { DecodedServerFrame } from "./gatewaySocketV2/adapters"; +import { + decodeServerFrame, + decodeServerFrameBinary, + encodeHelloFrame, + encodePongFrame, + encodeRequestFrame, + GATEWAY_V2_SUBPROTOCOL, +} from "./gatewaySocketV2/adapters"; +import type { + AgentStatus, + ChatQueueResponse, + ChatQueueSnapshot, + ConversationSummary, + CreateProjectFolderResponse, + CronManagePayload, + CronManageResponse, + GatewayChatRuntimeControls, + GatewayHistoryEvent, + GatewayProviderSummary, + GatewaySelectedModel, + HistoryDetail, + HistoryList, + HistoryListFilter, + HistoryShareStatus, + HistoryWorkdirsResponse, + MemoryManagePayload, + RunningConversationSummary, +} from "./gatewayTypes"; + +type StatusListener = (status: AgentStatus | null, error: string | null) => void; +type HistoryListener = (event: GatewayHistoryEvent) => void; +type SettingsListener = (event: GatewaySettingsSyncPayload) => void; +type GatewaySettingsUpdateResponse = { + accepted?: boolean; + message?: string; +}; +type TerminalListener = (event: TerminalEvent) => void; +type SftpTransferListener = (event: SftpTransferEvent) => void; +type ChatQueueListener = (snapshot: ChatQueueSnapshot) => void; +type ChatActivityListener = (event: ConversationActivityEvent) => void; +type ChatCommandUpdateListener = (update: ChatCommandUpdate) => void; +type WorkspaceActivityListener = (event: WorkspaceActivityEventPayload) => void; + +type PendingRequest = { + resolve: (value: any) => void; + reject: (reason?: unknown) => void; + timeoutId: number; +}; + +type GatewayRequestOptions = { + timeoutMs?: number; +}; + +type GatewayChatSystemSettings = { + executionMode?: string; + workdir?: string; +}; + +export type GatewayChatCommandInput = { + type: "chat.submit" | "chat.edit_resend"; + message: string; + conversationId?: string; + selectedModel?: GatewaySelectedModel; + systemSettings?: GatewayChatSystemSettings; + signal?: AbortSignal; + uploadedFiles?: PendingUploadedFile[]; + clientRequestId?: string; + runtimeControls?: GatewayChatRuntimeControls; + baseMessageRef?: HistoryMessageRef; + queuePolicy?: "auto" | "append" | "interrupt"; +}; + +type SkillListResponse = { + rootDir: string; + paths: string[]; + truncated: boolean; +}; + +export type SshKnownHostResetResult = { + deleted: number; +}; + +type MentionListResponse = { + entries: Array<{ path: string; kind: "file" | "dir"; hidden: boolean }>; + truncated: boolean; +}; + +type FsRoot = { + id: string; + path: string; + kind: "home" | "root" | "drive"; + label: string; +}; + +type FsRootsResponse = { + roots: FsRoot[]; +}; + +type FsListDirsResponse = { + path: string; + entries: Array<{ path: string; name: string }>; + truncated: boolean; +}; + +type FsListResponse = { + path?: string | null; + depth: number; + offset: number; + maxResults: number; + total: number; + hasMore: boolean; + entries: Array<{ path: string; kind: "file" | "dir"; hidden: boolean }>; +}; + +type FsWriteTextResponse = { + path: string; + mode: string; + existedBefore: boolean; + bytesWritten: number; + mtimeMs: number; + contentHash: string; + totalLines: number; +}; + +type FsReadEditableTextResponse = { + path: string; + content: string; + mtimeMs: number; + contentHash: string; + sizeBytes: number; + totalLines: number; +}; + +type FsReadWorkspaceImageResponse = { + path: string; + mimeType: string; + data: string; + sizeBytes: number; + mtimeMs: number; + contentHash: string; +}; + +export type ChatFileOpenResponse = { + action: "directory" | "editor" | "opened" | "preview" | "revealed"; + kind: "directory" | "file"; + workdir?: string; + path?: string; + line?: number; + endLine?: number; + column?: number; + outsideWorkspace: boolean; +}; + +type FsCreateDirResponse = { + path: string; + kind: "dir"; +}; + +type FsRenameResponse = { + fromPath: string; + path: string; + kind: "file" | "dir" | "symlink"; +}; + +type FsDeleteResponse = { + path: string; + kind: "file" | "dir" | "symlink"; +}; + +export type UploadedImagePreviewResponse = { + mimeType: string; + data: string; +}; + +export type { + LocalTunnelClient, + TunnelCreateInput, + TunnelHealth, + TunnelStateSnapshot, + TunnelStatus, + TunnelTtlSeconds, + TunnelUpdateInput, +} from "@liveagent/ui/lib/tunnels/constants"; + +type TunnelStateListener = (snapshot: TunnelStateSnapshot) => void; + +export type ManagedProcessRecordPayload = { + id: string; + label: string; + command: string; + cwd: string; + shell: string; + pid: number; + logPath: string; + startedAt: number; + finishedAt: number | null; + exitCode: number | null; + running: boolean; + isolated: boolean; + restored: boolean; +}; + +export type ManagedProcessStatePayload = { + revision: number; + agentOnline: boolean; + processes: ManagedProcessRecordPayload[]; +}; + +export type ManagedProcessLogPayload = { + content: string; + logPath: string; + truncated: boolean; +}; + +type ManagedProcessStateListener = (state: ManagedProcessStatePayload) => void; + +type RawManagedProcessRecord = { + id?: string; + label?: string; + command?: string; + cwd?: string; + shell?: string; + pid?: number; + log_path?: string; + started_at?: number; + finished_at?: number; + exit_code?: number; + running?: boolean; + isolated?: boolean; + restored?: boolean; +}; + +type RawManagedProcessStatePayload = { + revision?: number; + agent_online?: boolean; + processes?: RawManagedProcessRecord[]; +}; + +type RawManagedProcessOpPayload = { + action?: string; + stopped?: boolean; + state?: RawManagedProcessStatePayload; + log_content?: string; + log_path?: string; + log_truncated?: boolean; +}; + +type RawTunnelHealth = { + status?: string; + http_status?: number; + error?: string; + checked_at?: number; + rtt_ms?: number; +}; + +type RawTunnelStatus = { + id?: string; + slug?: string; + name?: string; + target_url?: string; + public_path?: string; + created_at?: number; + expires_at?: number; + active_connections?: number; + project_path_key?: string; + local?: RawTunnelHealth | null; +}; + +type RawTunnelStatePayload = { + revision?: number; + agent_online?: boolean; + relay?: RawTunnelHealth | null; + tunnels?: RawTunnelStatus[]; +}; + +type RawWorkspaceActivityPayload = { + workdir?: string; + revision?: number; + fs?: boolean; + git?: boolean; + changedPaths?: unknown; + truncated?: boolean; +}; + +type HistoryGetOptions = { + maxMessages?: number; +}; + +type SkillMetadataResponse = { + name?: string | null; + description?: string | null; +}; + +type SkillTextResponse = { + content: string; + truncated: boolean; +}; + +type SkillManagePayload = Record; + +type RawTerminalResponse = RawTerminalSnapshot & + RawTerminalShellOptionsResponse & + RawTerminalSshLatency & { + action?: string; + sessions?: RawTerminalSession[]; + sshTabs?: RawSshTerminalTabsSnapshot | null; + ssh_tabs?: RawSshTerminalTabsSnapshot | null; + sshLocalForwards?: RawSshLocalForwardSnapshot | null; + ssh_local_forwards?: RawSshLocalForwardSnapshot | null; + sshLocalForward?: RawSshLocalForwardAction | null; + ssh_local_forward?: RawSshLocalForwardAction | null; + sshLocalForwardPortAvailable?: boolean; + ssh_local_forward_port_available?: boolean; + }; + +type RawTerminalEvent = SharedRawTerminalEvent; + +class AsyncEventQueue implements AsyncIterable, AsyncIterator { + private values: T[] = []; + private waiters: Array<{ + resolve: (value: IteratorResult) => void; + reject: (reason?: unknown) => void; + }> = []; + private closed = false; + private failure: Error | null = null; + + push(value: T) { + if (this.closed || this.failure) { + return; + } + + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve({ value, done: false }); + return; + } + this.values.push(value); + } + + fail(error: Error) { + if (this.closed || this.failure) { + return; + } + this.failure = error; + const waiters = [...this.waiters]; + this.waiters = []; + for (const waiter of waiters) { + waiter.reject(error); + } + } + + close() { + if (this.closed) { + return; + } + this.closed = true; + const waiters = [...this.waiters]; + this.waiters = []; + for (const waiter of waiters) { + waiter.resolve({ value: undefined as T, done: true }); + } + } + + async next(): Promise> { + if (this.failure) { + return Promise.reject(this.failure); + } + if (this.values.length > 0) { + return { value: this.values.shift() as T, done: false }; + } + if (this.closed) { + return { value: undefined as T, done: true }; + } + + return new Promise>((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } + + async return(): Promise> { + this.close(); + return { value: undefined as T, done: true }; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } +} + +function asErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + const text = String(error ?? "").trim(); + return text || fallback; +} + +function buildWebSocketUrl() { + const origin = getRuntimeOrigin(); + if (!origin) { + throw new Error("Gateway WebSocket origin is unavailable"); + } + const url = new URL(origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + // v2 统一线协议端点(WebSocket + Protobuf 二进制帧)。 + url.pathname = "/ws/v2"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function createChatClientRequestId(input: GatewayChatCommandInput) { + const commandType = input.type.trim() || "chat.command"; + const conversationId = input.conversationId?.trim() || "new"; + return `webui-${commandType.replace(/[^a-z0-9._-]/gi, "_")}-${conversationId.replace( + /[^a-z0-9._-]/gi, + "_", + )}-${createUuid()}`; +} + +function buildChatCommandPayload(input: GatewayChatCommandInput) { + const systemSettings = input.systemSettings; + const clientRequestId = input.clientRequestId?.trim() || createChatClientRequestId(input); + return { + type: input.type, + payload: { + message: input.message, + conversation_id: input.conversationId ?? "", + client_request_id: clientRequestId, + execution_mode: systemSettings?.executionMode?.trim() || "text", + workdir: systemSettings?.workdir?.trim() || "", + uploaded_files: + input.uploadedFiles?.map((file) => ({ + relative_path: file.relativePath, + absolute_path: file.absolutePath, + file_name: file.fileName, + kind: file.kind, + size_bytes: file.sizeBytes, + })) ?? [], + selected_model: input.selectedModel + ? { + custom_provider_id: input.selectedModel.customProviderId, + model: input.selectedModel.model, + provider_type: input.selectedModel.providerType, + } + : undefined, + runtime_controls: input.runtimeControls + ? { + thinking_enabled: input.runtimeControls.thinkingEnabled, + native_web_search_enabled: input.runtimeControls.nativeWebSearchEnabled, + reasoning: input.runtimeControls.reasoning, + } + : undefined, + queue_policy: input.queuePolicy ?? "auto", + base_message_ref: input.baseMessageRef + ? buildHistoryMessageRefPayload(input.baseMessageRef) + : undefined, + }, + }; +} + +type RawChatCommandResponse = { + run_id?: string; + conversation_id?: string; + accepted_seq?: number; +}; + +type RawChatQueueResponse = { + accepted?: boolean; + message?: string; + snapshot_json?: string; + item_json?: string; + error_code?: string; + revision?: number; +}; + +type RawChatQueueEvent = { + conversation_id?: string; + snapshot_json?: string; + revision?: number; +}; + +function parseJsonPayload(raw: string | undefined, fallback: T): T { + const text = raw?.trim() ?? ""; + if (!text) return fallback; + try { + return JSON.parse(text) as T; + } catch { + return fallback; + } +} + +function normalizeChatQueueResponse(input: RawChatQueueResponse): ChatQueueResponse { + const normalized = input as RawChatQueueResponse & Partial; + return { + accepted: input.accepted === true, + message: input.message, + snapshot: + normalized.snapshot ?? + parseJsonPayload(input.snapshot_json, undefined), + item: normalized.item ?? parseJsonPayload(input.item_json, undefined), + errorCode: normalized.errorCode ?? input.error_code, + revision: input.revision, + }; +} + +function normalizeChatQueueSnapshot( + input: unknown, + fallbackConversationId?: string, + fallbackRevision?: number, +): ChatQueueSnapshot | null { + if (!input || typeof input !== "object") return null; + const raw = input as Partial; + const conversationId = + typeof raw.conversationId === "string" && raw.conversationId.trim() + ? raw.conversationId + : (fallbackConversationId ?? ""); + if (!conversationId) return null; + const revision = + typeof raw.revision === "number" && Number.isFinite(raw.revision) + ? raw.revision + : (fallbackRevision ?? 0); + return { + conversationId, + revision, + items: Array.isArray(raw.items) ? raw.items : [], + }; +} + +function normalizeChatQueueEvent( + input: RawChatQueueEvent | ChatQueueSnapshot, +): ChatQueueSnapshot | null { + const direct = normalizeChatQueueSnapshot(input); + if (direct) return direct; + const raw = input as RawChatQueueEvent; + return normalizeChatQueueSnapshot( + parseJsonPayload(raw.snapshot_json, null), + raw.conversation_id, + raw.revision, + ); +} + +const RECONNECT_INITIAL_DELAY_MS = 500; +const RECONNECT_MAX_DELAY_MS = 10_000; +const RECONNECT_NOTICE_DELAY_MS = 15_000; +const SOCKET_INBOUND_STALL_MS = 45_000; +const FOREGROUND_SOCKET_RECYCLE_IDLE_MS = 20_000; +const FOREGROUND_WAKEUP_RECENCY_MS = 15_000; +const SOCKET_CONNECT_TIMEOUT_MS = 10_000; +const SOCKET_AUTH_TIMEOUT_MS = 15_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const CHAT_PREPARE_REQUEST_TIMEOUT_MS = 2_500; +const CHAT_COMMAND_ACK_TIMEOUT_MS = 4_000; +// Fallback only: agent online/offline transitions arrive as pushed +// status.event frames, so the poll exists to reconcile missed pushes and +// TTL-derived fields (chat_runtime_ready), not to detect liveness. While the +// pill shows offline the poll IS the recovery path against a gateway that +// does not push (rollback), so it speeds up. +const STATUS_POLL_INTERVAL_MS = 30_000; +const STATUS_POLL_OFFLINE_INTERVAL_MS = 5_000; + +type RuntimeHost = { + location?: { + origin?: string; + href?: string; + }; + // Explicit browser-timer signatures rather than typeof setTimeout/etc: this + // code only ever runs against window/globalThis in a browser, but `typeof + // setTimeout` resolves ambiently and flips to NodeJS.Timeout if any + // dependency's types pull in @types/node, breaking the `number`-typed + // timer handle fields below. + setTimeout: (handler: () => void, timeout?: number) => number; + clearTimeout: (handle: number) => void; + setInterval: (handler: () => void, timeout?: number) => number; + clearInterval: (handle: number) => void; +}; + +const DEFAULT_HISTORY_LIST_PAGE = 1; +const DEFAULT_HISTORY_LIST_PAGE_SIZE = 80; +const MAX_HISTORY_LIST_PAGE_SIZE = 200; + +function getRuntimeHost(): RuntimeHost { + if (typeof window !== "undefined") { + return window as unknown as RuntimeHost; + } + return globalThis as unknown as RuntimeHost; +} + +function getRuntimeOrigin() { + return getGatewayOrigin(); +} + +function isDocumentHidden() { + return typeof document !== "undefined" && document.visibilityState === "hidden"; +} + +function isForegroundWakeupEvent(event?: Event) { + const type = event?.type ?? ""; + if (type === "pagehide" || type === "freeze") { + return false; + } + if (typeof document !== "undefined" && type === "visibilitychange") { + return document.visibilityState === "visible"; + } + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" && + type !== "online" + ) { + return false; + } + return true; +} + +function normalizePositiveInteger(value: number, fallback: number) { + if (!Number.isFinite(value)) { + return fallback; + } + const normalized = Math.trunc(value); + return normalized > 0 ? normalized : fallback; +} + +function sftpPathPayload(side: "local" | "remote", path = "") { + return { + side, + direction: side, + local_path: side === "local" ? String(path) : "", + remote_path: side === "remote" ? String(path) : "", + }; +} + +function normalizeTerminalBytes(value: unknown, fallbackText?: string): Uint8Array { + const bytes = normalizeTerminalByteContainer(value, (text) => { + try { + return Uint8Array.from(atob(text), (char) => char.charCodeAt(0)); + } catch { + return new TextEncoder().encode(text); + } + }); + return bytes.byteLength > 0 || !fallbackText ? bytes : new TextEncoder().encode(fallbackText); +} + +function applyTerminalSnapshotEvent(snapshot: Map, event: TerminalEvent) { + if (event.kind === "output") return; + + const sessionId = (event.sessionId || event.session?.id || "").trim(); + if (event.kind === "closed") { + if (sessionId) { + snapshot.delete(sessionId); + } + return; + } + + const session = event.session; + if (session?.id) { + snapshot.set(session.id, session); + } +} + +function replayTerminalSnapshot( + snapshot: Map, + listener: TerminalListener, +) { + const sessions = [...snapshot.values()].sort((a, b) => { + const leftProject = (a.projectPathKey || a.cwd || "").trim(); + const rightProject = (b.projectPathKey || b.cwd || "").trim(); + return leftProject.localeCompare(rightProject) || a.createdAt - b.createdAt; + }); + for (const session of sessions) { + listener({ + kind: "created", + sessionId: session.id, + projectPathKey: session.projectPathKey, + session, + }); + } +} + +function normalizeTunnelHealthStatus(input: unknown): TunnelHealth["status"] { + return input === "ok" || input === "failed" ? input : "unknown"; +} + +function normalizeTunnelHealth(input: RawTunnelHealth | null | undefined): TunnelHealth | null { + if (!input || typeof input !== "object") { + return null; + } + return { + status: normalizeTunnelHealthStatus(input.status), + httpStatus: Number(input.http_status ?? 0), + error: (input.error ?? "").trim(), + checkedAt: Number(input.checked_at ?? 0), + rttMs: Number(input.rtt_ms ?? 0), + }; +} + +function normalizeTunnelStatus(input: RawTunnelStatus): TunnelStatus { + return { + id: input.id?.trim() ?? "", + slug: input.slug?.trim() ?? "", + name: input.name?.trim() ?? "", + targetUrl: input.target_url ?? "", + publicPath: input.public_path ?? "", + createdAt: Number(input.created_at ?? 0), + expiresAt: Number(input.expires_at ?? 0), + activeConnections: Number(input.active_connections ?? 0), + projectPathKey: (input.project_path_key ?? "").trim(), + local: normalizeTunnelHealth(input.local), + }; +} + +function normalizeManagedProcessState( + input: RawManagedProcessStatePayload, +): ManagedProcessStatePayload { + const processes = Array.isArray(input.processes) ? input.processes : []; + return { + revision: Number(input.revision ?? 0), + agentOnline: input.agent_online === true, + processes: processes.map((record) => ({ + id: String(record.id ?? ""), + label: String(record.label ?? ""), + command: String(record.command ?? ""), + cwd: String(record.cwd ?? ""), + shell: String(record.shell ?? ""), + pid: Number(record.pid ?? 0), + logPath: String(record.log_path ?? ""), + startedAt: Number(record.started_at ?? 0), + finishedAt: typeof record.finished_at === "number" ? record.finished_at : null, + exitCode: typeof record.exit_code === "number" ? record.exit_code : null, + running: record.running === true, + isolated: record.isolated === true, + restored: record.restored === true, + })), + }; +} + +function normalizeTunnelStateSnapshot(input: RawTunnelStatePayload): TunnelStateSnapshot { + return { + revision: Number(input.revision ?? 0), + agentOnline: input.agent_online === true, + relay: normalizeTunnelHealth(input.relay), + tunnels: (input.tunnels ?? []).map(normalizeTunnelStatus), + }; +} + +function normalizeWorkspaceActivity(input: RawWorkspaceActivityPayload): WorkspaceActivity | null { + const workdir = typeof input.workdir === "string" ? input.workdir : ""; + if (!workdir) { + return null; + } + return { + workdir, + revision: Number(input.revision ?? 0), + fs: input.fs === true, + git: input.git === true, + changedPaths: Array.isArray(input.changedPaths) + ? input.changedPaths.filter((path): path is string => typeof path === "string") + : [], + truncated: input.truncated === true, + }; +} + +function normalizeHistoryListPage(page: number) { + return normalizePositiveInteger(page, DEFAULT_HISTORY_LIST_PAGE); +} + +function normalizeHistoryListPageSize(pageSize: number) { + return Math.min( + normalizePositiveInteger(pageSize, DEFAULT_HISTORY_LIST_PAGE_SIZE), + MAX_HISTORY_LIST_PAGE_SIZE, + ); +} + +function isRecoverableGatewayTransportError(error: unknown) { + const message = asErrorMessage(error, ""); + return ( + message.startsWith("Gateway WebSocket disconnected") || + message === "Gateway WebSocket is not connected" || + message.startsWith("Gateway transport stalled") + ); +} + +function isRequestTimeoutError(error: unknown) { + return asErrorMessage(error, "").startsWith("Gateway WebSocket request timed out"); +} + +function isConnectionSetupTimeoutError(error: unknown) { + const message = asErrorMessage(error, ""); + return ( + message === "Gateway WebSocket connection timed out" || + message === "Gateway WebSocket auth timed out" + ); +} + +function isUnsupportedChatPrepareError(error: unknown) { + const message = asErrorMessage(error, "").toLowerCase(); + return ( + message.includes("unsupported request type") || + message.includes("unsupported chat.prepare") || + message.includes("unknown request type") + ); +} + +const RECOVERABLE_MEMORY_MANAGE_COMMANDS = new Set([ + "memory_list", + "memory_read", + "memory_search", + "memory_organize_run_create", + "memory_organize_run_update", + "memory_organize_run_list", + "memory_organize_run_read", + "memory_organize_due_claim", + "memory_organize_due_complete", + "memory_index_overview", + "memory_recent_rejections", + "memory_paths_info", + "memory_today_local_date", + "memory_today_daily", +]); + +function shouldRecoverMemoryManageRequest(payload: MemoryManagePayload) { + const command = typeof payload.command === "string" ? payload.command.trim() : ""; + return RECOVERABLE_MEMORY_MANAGE_COMMANDS.has(command); +} + +const ACTIVE_AGENT_STORAGE_KEY = "liveagent.gateway.activeAgent"; +const AGENT_ID_OPTIONAL_REQUEST_TYPES = new Set(["agent.list", "chat.activities"]); + +function requestRequiresAgentId(type: string): boolean { + return !AGENT_ID_OPTIONAL_REQUEST_TYPES.has(type); +} + +function loadPersistedActiveAgent(): string { + try { + return (globalThis.localStorage?.getItem(ACTIVE_AGENT_STORAGE_KEY) ?? "").trim(); + } catch { + return ""; + } +} + +function persistActiveAgent(agentId: string) { + try { + if (agentId) { + globalThis.localStorage?.setItem(ACTIVE_AGENT_STORAGE_KEY, agentId); + } else { + globalThis.localStorage?.removeItem(ACTIVE_AGENT_STORAGE_KEY); + } + } catch { + // 隐私模式等场景不可写:选择只在本页生命周期内生效。 + } +} + +export class GatewayWebSocketClient { + readonly terminalStream: BrowserGatewayTerminalStreamClient; + private socket: WebSocket | null = null; + private connectPromise: Promise | null = null; + private authenticated = false; + private requestSeq = 0; + private pending = new Map(); + private disposed = false; + private statusListeners = new Set(); + private historyListeners = new Set(); + private settingsListeners = new Set(); + private terminalListeners = new Set(); + private sftpTransferListeners = new Set(); + private chatQueueListeners = new Set(); + private chatActivityListeners = new Set(); + private chatCommandUpdateListeners = new Set(); + private connectionListeners = new Set<(connected: boolean) => void>(); + private connectionState = false; + private tunnelStateListeners = new Set(); + private workspaceActivityListeners = new Map>(); + private lastTunnelState: TunnelStateSnapshot | null = null; + // Server tunnel.state revisions are only monotonic within one gateway + // process; this guard is reset on disconnect so a restarted gateway's + // snapshots are not dropped. Subscribers see a client-side monotonic + // revision instead. + private lastTunnelStateServerRevision = 0; + private tunnelStateRevisionCounter = 0; + private processStateListeners = new Set(); + private lastProcessState: ManagedProcessStatePayload | null = null; + readonly conversationStreams = new ConversationStreamClient({ + request: (type, payload, options) => this.request(type, payload, options), + }); + private terminalSessionSnapshot = new Map(); + private statusPollTimer: number | null = null; + private statusPollingActive = false; + private statusRefreshInFlight = false; + private lastStatus: AgentStatus | null = null; + private lastStatusError: string | null = null; + private lastInboundAt = 0; + private reconnectTimer: number | null = null; + private reconnectNoticeTimer: number | null = null; + private reconnectAttempt = 0; + private reconnecting = false; + // hello 判定鉴权失败(随后 4401 关闭):置位抑制自动重连,显式新连接尝试会清除。 + private authRejected = false; + private lastForegroundWakeupAt = 0; + // A hidden tab must not paint offline state the user cannot see from + // throttled timers; the verdict is deferred until a foreground recheck. + private offlineReassessmentPending = false; + private prepareRuntimePromise: Promise | null = null; + private readonly reconnectWakeup = (event?: Event) => { + this.noteForegroundWakeup(event); + }; + + // activeAgentId 始终是明确目标;首次连接从 Agent 目录自动选择并持久化。 + private activeAgentId = ""; + private activeAgentSelectionPromise: Promise | null = null; + // agents 目录:由打标 status 事件与 agent_list 响应维护,含离线条目。 + private agents = new Map(); + private agentsListeners = new Set<(agents: AgentStatus[]) => void>(); + private pendingAgentEvents = new Map>(); + + constructor(private readonly token: string) { + this.activeAgentId = loadPersistedActiveAgent(); + this.terminalStream = new BrowserGatewayTerminalStreamClient(token, () => this.activeAgentId); + this.installReconnectWakeups(); + } + + getActiveAgent(): string { + return this.activeAgentId; + } + + // setActiveAgent 只接受明确目标;切换后重连,以目标 Agent 的快照重画状态。 + setActiveAgent(agentId: string): void { + const next = agentId.trim(); + if (!next) { + throw new Error("agent_id is required"); + } + this.switchActiveAgent(next, true); + } + + private switchActiveAgent(agentId: string, reconnect: boolean): void { + if (agentId === this.activeAgentId) { + return; + } + this.activeAgentId = agentId; + persistActiveAgent(agentId); + if (reconnect && this.socket && this.authenticated) { + this.handleDisconnect(new Error("active agent switched")); + } else if (!reconnect && this.socket && this.authenticated) { + this.flushPendingAgentEvents(agentId); + this.conversationStreams.handleConnected(); + this.handleWorkspaceActivityConnected(); + } + } + + // listAgents 拉取 Agent 目录(含离线与仅签发凭证的条目)。 + async listAgents(): Promise { + if (!this.activeAgentId) { + await this.ensureConnected(); + await this.ensureActiveAgent(); + await this.ensureConnected(); + return this.snapshotAgents(); + } + const result = await this.requestWithRecovery<{ agents: AgentStatus[] }>("agent.list", {}); + const agents = Array.isArray(result?.agents) ? result.agents : []; + this.updateAgentDirectory(agents); + this.reconcileActiveAgent(agents); + return this.snapshotAgents(); + } + + subscribeAgents(listener: (agents: AgentStatus[]) => void): () => void { + this.agentsListeners.add(listener); + if (this.agents.size > 0) { + listener(this.snapshotAgents()); + } + return () => { + this.agentsListeners.delete(listener); + }; + } + + private updateAgentDirectory(agents: AgentStatus[]): void { + this.agents.clear(); + for (const agent of agents) { + const agentId = (agent.agent_id ?? "").trim(); + if (agentId) { + this.agents.set(agentId, agent); + } + } + this.emitAgents(); + } + + private reconcileActiveAgent(agents: AgentStatus[], reconnect = true): string { + const current = this.activeAgentId; + if (current && agents.some((agent) => (agent.agent_id ?? "").trim() === current)) { + return current; + } + const preferred = [...agents] + .filter((agent) => (agent.agent_id ?? "").trim() !== "") + .sort((left, right) => { + if (left.online !== right.online) { + return left.online ? -1 : 1; + } + return (left.agent_id ?? "").localeCompare(right.agent_id ?? ""); + })[0]; + const agentId = (preferred?.agent_id ?? "").trim(); + if (agentId) { + this.switchActiveAgent(agentId, reconnect); + } else if (current) { + this.switchActiveAgent("", reconnect); + } + return agentId; + } + + private async ensureActiveAgent(): Promise { + if (this.activeAgentId) { + return this.activeAgentId; + } + if (this.activeAgentSelectionPromise) { + return this.activeAgentSelectionPromise; + } + this.activeAgentSelectionPromise = (async () => { + const result = await this.sendConnectedRequest<{ agents: AgentStatus[] }>( + "agent.list", + {}, + undefined, + "", + ); + const agents = Array.isArray(result?.agents) ? result.agents : []; + this.updateAgentDirectory(agents); + const agentId = this.reconcileActiveAgent(agents, false); + if (!agentId) { + throw new Error("No Agent is available"); + } + return agentId; + })().finally(() => { + this.activeAgentSelectionPromise = null; + }); + return this.activeAgentSelectionPromise; + } + + private matchesActiveAgent(agentId: string): boolean { + return this.activeAgentId !== "" && agentId === this.activeAgentId; + } + + private bufferAgentEvent(agentId: string, type: string, payload: unknown): void { + const buffered = this.pendingAgentEvents.get(agentId) ?? []; + if (buffered.length >= 256) { + buffered.shift(); + } + buffered.push({ type, payload }); + this.pendingAgentEvents.set(agentId, buffered); + } + + private flushPendingAgentEvents(agentId: string): void { + const buffered = this.pendingAgentEvents.get(agentId) ?? []; + this.pendingAgentEvents.clear(); + for (const event of buffered) { + this.handleEvent(event.type, event.payload); + } + } + + private snapshotAgents(): AgentStatus[] { + return [...this.agents.values()].sort((a, b) => + (a.agent_id ?? "").localeCompare(b.agent_id ?? ""), + ); + } + + private emitAgents() { + if (this.agentsListeners.size === 0) { + return; + } + const snapshot = this.snapshotAgents(); + for (const listener of this.agentsListeners) { + listener(snapshot); + } + } + + noteForegroundWakeup(event?: Event) { + if (this.disposed || !isForegroundWakeupEvent(event)) { + return; + } + const now = Date.now(); + this.lastForegroundWakeupAt = now; + const reassess = this.offlineReassessmentPending; + this.offlineReassessmentPending = false; + if ( + this.socket?.readyState === WebSocket.OPEN && + this.authenticated && + this.shouldRecycleAuthenticatedSocket(now) + ) { + this.handleDisconnect(this.buildTransportStallError("after page restore")); + this.scheduleReconnectNotice(); + return; + } + if (this.socket?.readyState === WebSocket.OPEN && this.authenticated) { + // Healthy socket: reconcile the status truth immediately so a verdict + // deferred while hidden resolves to fresh state, not a stale banner. + if (this.statusListeners.size > 0) { + void this.refreshStatus(); + } + return; + } + if (reassess || this.reconnectNoticeTimer !== null) { + // The existing notice delay doubles as the post-wake grace: nothing + // paints offline until a reconnect attempt has had its 15s to settle. + // A notice armed before (or while) hidden restarts from wake — it must + // not fire seconds after refocus with most of its window pre-spent. + this.clearReconnectNoticeTimer(); + this.scheduleReconnectNotice(); + } + this.scheduleReconnect(0); + } + + async getStatus(): Promise { + const status = await this.requestWithRecovery("status.get", {}); + this.lastStatus = status; + this.lastStatusError = null; + return status; + } + + async prepareChatRuntime(reason?: string): Promise { + if (this.prepareRuntimePromise) { + return this.prepareRuntimePromise; + } + this.noteForegroundWakeup(); + this.prepareRuntimePromise = (async () => { + await this.ensureConnected(); + let status: AgentStatus; + try { + status = await this.request( + "chat.prepare", + { + reason: reason?.trim() || "", + }, + { timeoutMs: CHAT_PREPARE_REQUEST_TIMEOUT_MS }, + ); + } catch (error) { + if (!isUnsupportedChatPrepareError(error)) { + throw error; + } + // Rolling-upgrade compatibility: old gateways do not expose the real + // prepare route. A status read preserves the old behavior until the + // server is upgraded, while new gateways actively wake the runtime. + status = await this.getStatus(); + } + this.emitStatus(status, null); + return status; + })().finally(() => { + this.prepareRuntimePromise = null; + }); + return this.prepareRuntimePromise; + } + + subscribeStatus(listener: StatusListener): () => void { + this.statusListeners.add(listener); + if (this.lastStatus || this.lastStatusError) { + listener(this.lastStatus, this.lastStatusError); + } + if (this.statusListeners.size === 1) { + this.startStatusPolling(); + } + return () => { + this.statusListeners.delete(listener); + if (this.statusListeners.size === 0) { + this.stopStatusPolling(); + } + }; + } + + subscribeHistory(listener: HistoryListener): () => void { + this.historyListeners.add(listener); + return () => { + this.historyListeners.delete(listener); + }; + } + + subscribeSettings(listener: SettingsListener): () => void { + this.settingsListeners.add(listener); + return () => { + this.settingsListeners.delete(listener); + }; + } + + subscribeTerminal(listener: TerminalListener): () => void { + this.terminalListeners.add(listener); + replayTerminalSnapshot(this.terminalSessionSnapshot, listener); + return () => { + this.terminalListeners.delete(listener); + }; + } + + subscribeTunnelState(listener: TunnelStateListener): () => void { + this.tunnelStateListeners.add(listener); + if (this.lastTunnelState) { + listener(this.lastTunnelState); + } + return () => { + this.tunnelStateListeners.delete(listener); + }; + } + + subscribeProcessState(listener: ManagedProcessStateListener): () => void { + this.processStateListeners.add(listener); + if (this.lastProcessState) { + listener(this.lastProcessState); + } + return () => { + this.processStateListeners.delete(listener); + }; + } + + subscribeSftpTransfers(listener: SftpTransferListener): () => void { + this.sftpTransferListeners.add(listener); + return () => { + this.sftpTransferListeners.delete(listener); + }; + } + + // Per-workdir workspace activity subscription. The first listener of a + // workdir sends workspace.subscribe; the last one leaving sends + // workspace.unsubscribe. On reconnect every registered workdir is + // re-subscribed and its listeners receive `{ kind: "reset" }` because + // events may have been missed while the socket was down. + subscribeWorkspaceActivity(workdir: string, listener: WorkspaceActivityListener): () => void { + const normalized = workdir.trim(); + if (!normalized) { + return () => {}; + } + let listeners = this.workspaceActivityListeners.get(normalized); + const isFirst = !listeners; + if (!listeners) { + listeners = new Set(); + this.workspaceActivityListeners.set(normalized, listeners); + } + listeners.add(listener); + if (isFirst) { + if (this.socket?.readyState === WebSocket.OPEN && this.authenticated) { + void this.sendWorkspaceSubscribe(normalized); + } else { + // The post-auth replay issues the subscribe for every registered + // workdir, this one included. + void this.ensureConnected().catch(() => { + this.scheduleReconnect(); + }); + } + } + return () => { + const current = this.workspaceActivityListeners.get(normalized); + if (!current?.delete(listener)) { + return; + } + if (current.size === 0) { + this.workspaceActivityListeners.delete(normalized); + void this.sendWorkspaceUnsubscribe(normalized); + } + }; + } + + private async sendWorkspaceSubscribe(workdir: string) { + try { + await this.request("workspace.subscribe", { workdir }); + } catch { + // The post-auth replay retries every registered subscription. + } + } + + private async sendWorkspaceUnsubscribe(workdir: string) { + // Never resurrect a connection just to unsubscribe: a dead socket already + // dropped the server-side subscription. + if (this.socket?.readyState !== WebSocket.OPEN || !this.authenticated) { + return; + } + try { + await this.request("workspace.unsubscribe", { workdir }); + } catch { + // Connection loss clears server-side subscriptions anyway. + } + } + + private handleWorkspaceActivityConnected() { + const reset: WorkspaceActivityEventPayload = { kind: "reset" }; + for (const [workdir, listeners] of this.workspaceActivityListeners) { + void this.sendWorkspaceSubscribe(workdir); + for (const listener of [...listeners]) { + listener(reset); + } + } + } + + private emitWorkspaceActivity(activity: WorkspaceActivity) { + const listeners = this.workspaceActivityListeners.get(activity.workdir); + if (!listeners) { + return; + } + for (const listener of [...listeners]) { + listener(activity); + } + } + + subscribeChatQueue(listener: ChatQueueListener): () => void { + this.chatQueueListeners.add(listener); + return () => { + this.chatQueueListeners.delete(listener); + }; + } + + async chatQueueGet(conversationId: string): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.get", { + conversation_id: conversationId, + }), + ); + } + + async chatQueueGetItem(conversationId: string, itemId: string): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.get_item", { + conversation_id: conversationId, + item_id: itemId, + }), + ); + } + + async chatQueueRunNow(conversationId: string, itemId: string): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.run_now", { + conversation_id: conversationId, + item_id: itemId, + }), + ); + } + + // 用量环触发的手动压缩:中继到桌面端执行(受理即回包,进度与带 + // operationId 的终态分别经聊天流回传)。 + async chatQueueCompactNow( + conversationId: string, + operationId: string, + ): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.compact_now", { + conversation_id: conversationId, + request_json: JSON.stringify({ operationId }), + }), + ); + } + + async chatQueueMove( + conversationId: string, + itemId: string, + direction: "up" | "down", + ): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.move", { + conversation_id: conversationId, + item_id: itemId, + direction, + }), + ); + } + + async chatQueueRemove(conversationId: string, itemId: string): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.remove", { + conversation_id: conversationId, + item_id: itemId, + }), + ); + } + + async chatQueueEditBegin(conversationId: string, itemId: string): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.edit_begin", { + conversation_id: conversationId, + item_id: itemId, + }), + ); + } + + async chatQueueEditCommit(input: { + conversationId: string; + itemId: string; + revision: number; + draftJson: string; + uploadedFilesJson: string; + }): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.edit_commit", { + conversation_id: input.conversationId, + item_id: input.itemId, + revision: input.revision, + draft_json: input.draftJson, + uploaded_files_json: input.uploadedFilesJson, + }), + ); + } + + async chatQueueEditCancel(conversationId: string, itemId: string): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.edit_cancel", { + conversation_id: conversationId, + item_id: itemId, + }), + ); + } + + /** 应答桌面端挂起的 AskUserQuestion:item_id 为 toolCallId,request_json 为选择数组。 */ + async chatQueueToolAnswer( + conversationId: string, + toolCallId: string, + answersJson: string, + ): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.tool_answer", { + conversation_id: conversationId, + item_id: toolCallId, + request_json: answersJson, + }), + ); + } + + /** 提交对桌面端挂起工具审批的决定:item_id 为 toolCallId,request_json 为 {"decision":...}。 */ + async chatQueueToolApproval( + conversationId: string, + toolCallId: string, + decisionJson: string, + ): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.tool_approval", { + conversation_id: conversationId, + item_id: toolCallId, + request_json: decisionJson, + }), + ); + } + + // Submit a chat command. Streaming does not hang off the command: the + // conversation subscription (persistent, run-agnostic) carries the reply. + async chatCommand(input: GatewayChatCommandInput): Promise { + if (this.token.trim() === "") { + throw new Error("Gateway token is required"); + } + // Build exactly once: the gateway deduplicates by client_request_id, so a + // lost acknowledgement can be retried after reconnect without dispatching + // a second desktop run. Rebuilding here could generate a different id for + // callers that omitted one and would defeat that guarantee. + const payload = buildChatCommandPayload(input); + const request = () => + this.request("chat.command", payload, { + timeoutMs: CHAT_COMMAND_ACK_TIMEOUT_MS, + }); + let response: RawChatCommandResponse; + try { + response = await request(); + } catch (error) { + if ( + this.disposed || + (!isRecoverableGatewayTransportError(error) && + !isRequestTimeoutError(error) && + !isConnectionSetupTimeoutError(error)) + ) { + throw error; + } + if (this.socket || this.connectPromise) { + this.handleDisconnect( + this.buildTransportStallError("while recovering chat command acknowledgement"), + ); + } + try { + await this.ensureConnected(); + } catch { + // The forced disconnect may have rejected an in-flight connect + // attempt whose settled promise ensureConnected replays; one fresh + // attempt follows it so the retry actually reaches the wire. + await this.ensureConnected(); + } + response = await request(); + } + const runId = String(response.run_id ?? "").trim(); + if (!runId) { + throw new Error("Gateway chat command returned no run_id"); + } + return { + runId, + conversationId: + String(response.conversation_id ?? "").trim() || input.conversationId?.trim() || "", + acceptedSeq: + typeof response.accepted_seq === "number" && Number.isFinite(response.accepted_seq) + ? response.accepted_seq + : 0, + }; + } + + // Persistent per-conversation stream subscription with built-in resume. + subscribeConversationStream( + conversationId: string, + handlers: ConversationStreamHandlers, + ): () => void { + const cleanup = this.conversationStreams.subscribe(conversationId, handlers); + // Establish the connection (and thereby the subscription) eagerly. + // Authentication is the single connection notification point; subscribe() + // handles the already-connected case itself, so replaying handleConnected + // here would issue every chat.subscribe twice. + void this.ensureConnected().catch(() => { + this.scheduleReconnect(); + }); + return cleanup; + } + + subscribeChatActivity(listener: ChatActivityListener): () => void { + this.chatActivityListeners.add(listener); + return () => { + this.chatActivityListeners.delete(listener); + }; + } + + // Authenticated-connection state: `true` fires when auth completes (the + // same point conversation streams resume), `false` when the socket drops. + // Late subscribers immediately receive the current state. + subscribeConnection(listener: (connected: boolean) => void): () => void { + this.connectionListeners.add(listener); + listener(this.connectionState); + return () => { + this.connectionListeners.delete(listener); + }; + } + + // On-demand snapshot of every active run (same items as history.list's + // running_conversations, without the list). + async listChatActivities(): Promise { + const response = await this.requestWithRecovery<{ + running_conversations?: RunningConversationSummary[]; + }>("chat.activities", {}); + return Array.isArray(response?.running_conversations) ? response.running_conversations : []; + } + + // Re-issue chat.subscribe for a subscribed conversation from its cursor + // (gap-recovery path); no-op when the conversation is not subscribed. + resyncConversation(conversationId: string): void { + this.conversationStreams.resync(conversationId); + } + + subscribeChatCommandUpdates(listener: ChatCommandUpdateListener): () => void { + this.chatCommandUpdateListeners.add(listener); + return () => { + this.chatCommandUpdateListeners.delete(listener); + }; + } + + async cancelChat(conversationId: string, runId?: string): Promise { + const normalized = conversationId.trim(); + if (!normalized) { + return; + } + await this.request("chat.cancel", { + conversation_id: normalized, + run_id: runId?.trim() || undefined, + }); + } + + async processSnapshot(): Promise { + const payload = await this.requestWithRecovery( + "process.snapshot", + {}, + ); + const state = normalizeManagedProcessState(payload ?? {}); + this.emitProcessState(state); + return state; + } + + async processStop(processId: string): Promise { + const payload = await this.requestWithRecovery("process.stop", { + process_id: processId, + }); + return payload?.state ? normalizeManagedProcessState(payload.state) : null; + } + + async processClear(processId?: string): Promise { + const payload = await this.requestWithRecovery( + "process.clear", + processId ? { process_id: processId } : {}, + ); + return payload?.state ? normalizeManagedProcessState(payload.state) : null; + } + + async processReadLog(processId: string, maxBytes?: number): Promise { + const payload = await this.requestWithRecovery( + "process.read_log", + maxBytes ? { process_id: processId, max_bytes: maxBytes } : { process_id: processId }, + ); + return { + content: String(payload?.log_content ?? ""), + logPath: String(payload?.log_path ?? ""), + truncated: payload?.log_truncated === true, + }; + } + + async cronManage(payload: CronManagePayload): Promise { + return this.request("cron.manage", payload); + } + + async memoryManage(payload: MemoryManagePayload): Promise { + if (shouldRecoverMemoryManageRequest(payload)) { + return this.requestWithRecovery("memory.manage", payload); + } + return this.request("memory.manage", payload); + } + + async gitRequest( + action: string, + workdir: string, + args: Record = {}, + ): Promise { + const requestType = `git.${action}`; + if ( + action === "status" || + action === "branches" || + action === "diff" || + action === "log" || + action === "commit_details" || + action === "compare_commit_with_remote" || + action === "commit_diff" + ) { + return this.requestWithRecovery(requestType, { workdir, args }); + } + return this.request(requestType, { workdir, args }); + } + + async sftpList(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path?: string; + }): Promise { + return normalizeSftpListResponse( + await this.request("sftp.list", { + session_id: params.sessionId, + project_path_key: params.projectPathKey, + workdir: params.workdir, + ...sftpPathPayload(params.side, params.path ?? ""), + }), + ); + } + + async sftpStat(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path?: string; + }): Promise { + return normalizeSftpStatResponse( + await this.request("sftp.stat", { + session_id: params.sessionId, + project_path_key: params.projectPathKey, + workdir: params.workdir, + ...sftpPathPayload(params.side, params.path ?? ""), + }), + ); + } + + async sftpMkdir(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path: string; + }): Promise { + return normalizeSftpActionResponse( + await this.request("sftp.mkdir", { + session_id: params.sessionId, + project_path_key: params.projectPathKey, + workdir: params.workdir, + ...sftpPathPayload(params.side, params.path), + }), + ); + } + + async sftpRename(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + fromPath: string; + toPath: string; + }): Promise { + return normalizeSftpActionResponse( + await this.request("sftp.rename", { + session_id: params.sessionId, + project_path_key: params.projectPathKey, + workdir: params.workdir, + side: params.side, + direction: params.side, + from_path: params.fromPath, + to_path: params.toPath, + }), + ); + } + + async sftpDelete(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path: string; + recursive?: boolean; + }): Promise { + return normalizeSftpActionResponse( + await this.request("sftp.delete", { + session_id: params.sessionId, + project_path_key: params.projectPathKey, + workdir: params.workdir, + ...sftpPathPayload(params.side, params.path), + recursive: params.recursive ?? false, + }), + ); + } + + async sftpTransfer(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + direction: "upload" | "download"; + sourcePath: string; + targetPath: string; + recursive?: boolean; + overwrite?: boolean; + }): Promise { + return normalizeSftpTransferResponse( + await this.request("sftp.transfer", { + session_id: params.sessionId, + project_path_key: params.projectPathKey, + workdir: params.workdir, + direction: params.direction, + from_path: params.sourcePath, + target_path: params.targetPath, + recursive: params.recursive ?? false, + overwrite: params.overwrite ?? false, + }), + ); + } + + async sftpCancelTransfer(params: { sessionId: string; transferId: string }): Promise { + await this.request("sftp.cancel", { + session_id: params.sessionId, + from_path: params.transferId, + }); + } + + async terminalShellOptions(): Promise { + return normalizeTerminalShellOptions( + await this.requestWithRecovery("terminal.shell_options", {}), + ); + } + + async listTerminals(projectPathKey?: string): Promise { + const projectKey = projectPathKey?.trim() ?? ""; + const response = await this.requestWithRecovery( + "terminal.list", + projectKey ? { project_path_key: projectKey } : {}, + ); + return (response.sessions ?? []).map(normalizeTerminalSession); + } + + async createTerminal(params: { + cwd: string; + projectPathKey: string; + shell?: string; + title?: string; + cols?: number; + rows?: number; + }): Promise { + return normalizeTerminalSnapshot( + await this.request("terminal.create", { + ...buildTerminalCreatePayload(params), + }), + normalizeTerminalBytes, + ); + } + + async createSshTerminal(params: { + cwd: string; + projectPathKey: string; + hostId: string; + title?: string; + cols?: number; + rows?: number; + sftpEnabled?: boolean; + }): Promise { + return normalizeTerminalSshCreateResult( + await this.request("terminal.create_ssh", { + ...buildTerminalSshCreatePayload(params), + }), + normalizeTerminalBytes, + ); + } + + async answerSshTerminalPrompt(params: { + promptId: string; + answer?: string; + trustHostKey?: boolean; + }): Promise { + return normalizeTerminalSshCreateResult( + await this.request("terminal.answer_ssh_prompt", { + ...buildTerminalSshPromptAnswerPayload(params), + }), + normalizeTerminalBytes, + ); + } + + async cancelSshTerminalPrompt(promptId: string): Promise { + await this.request("terminal.cancel_ssh_prompt", { + prompt_id: promptId, + }); + } + + async reconnectSshTerminal(sessionId: string, projectPathKey?: string): Promise { + const response = await this.request("terminal.ssh_reconnect", { + session_id: sessionId, + project_path_key: projectPathKey, + }); + if (!response.session) { + throw new Error("Terminal response did not include a session"); + } + return normalizeTerminalSession(response.session); + } + + async sshTerminalLatency( + sessionId: string, + projectPathKey?: string, + ): Promise { + const latency = normalizeTerminalSshLatency( + await this.request("terminal.ssh_latency", { + session_id: sessionId, + project_path_key: projectPathKey, + }), + ); + return { ...latency, sessionId }; + } + + async listSshTerminalTabs(projectPathKey: string): Promise { + const response = await this.requestWithRecovery("terminal.ssh_tabs_list", { + project_path_key: projectPathKey, + }); + return normalizeSshTerminalTabsSnapshot(response.sshTabs ?? response.ssh_tabs); + } + + async openSshTerminalTab(params: { + sessionId: string; + kind: SshTerminalTabKind; + }): Promise { + const response = await this.request("terminal.ssh_tab_open", { + session_id: params.sessionId, + tab_kind: params.kind, + }); + return normalizeSshTerminalTabsSnapshot(response.sshTabs ?? response.ssh_tabs); + } + + async closeSshTerminalTab(tabId: string): Promise { + const response = await this.request("terminal.ssh_tab_close", { + tab_id: tabId, + }); + return normalizeSshTerminalTabsSnapshot(response.sshTabs ?? response.ssh_tabs); + } + + async listSshLocalForwards(params?: { + sessionId?: string; + projectPathKey?: string; + }): Promise { + const response = await this.requestWithRecovery( + "terminal.ssh_local_forward_list", + { + session_id: params?.sessionId, + project_path_key: params?.projectPathKey, + }, + ); + return normalizeSshLocalForwardSnapshot( + response.sshLocalForwards ?? response.ssh_local_forwards ?? {}, + ); + } + + async startSshLocalForward(params: { + sessionId: string; + projectPathKey?: string; + remoteHost: string; + remotePort: number; + localPort?: number; + }): Promise { + const response = await this.request("terminal.ssh_local_forward_start", { + session_id: params.sessionId, + project_path_key: params.projectPathKey, + remote_host: params.remoteHost, + remote_port: params.remotePort, + local_port: params.localPort ?? 0, + }); + const action = response.sshLocalForward ?? response.ssh_local_forward; + if (!action) { + throw new Error("Terminal response did not include a forward"); + } + return normalizeSshLocalForwardAction(action); + } + + async stopSshLocalForward(params: { + forwardId: string; + sessionId?: string; + }): Promise { + const response = await this.request("terminal.ssh_local_forward_stop", { + forward_id: params.forwardId, + session_id: params.sessionId, + }); + const action = response.sshLocalForward ?? response.ssh_local_forward; + if (!action) { + throw new Error("Terminal response did not include a forward"); + } + return normalizeSshLocalForwardAction(action); + } + + async checkSshLocalForwardPort(localPort: number): Promise { + const response = await this.request( + "terminal.ssh_local_forward_check_port", + { local_port: localPort }, + ); + return Boolean( + response.sshLocalForwardPortAvailable ?? response.ssh_local_forward_port_available, + ); + } + + subscribeSshLocalForward(listener: (event: SshLocalForwardEvent) => void): () => void { + return this.subscribeTerminal((event) => { + if (event.kind === "ssh_local_forward" && event.sshLocalForward) { + listener(event.sshLocalForward); + } + }); + } + + async renameTerminal( + sessionId: string, + title: string, + projectPathKey?: string, + ): Promise { + const response = await this.request("terminal.rename", { + session_id: sessionId, + project_path_key: projectPathKey, + title, + }); + if (!response.session) { + throw new Error("Terminal response did not include a session"); + } + return normalizeTerminalSession(response.session); + } + + async closeTerminal(sessionId: string, projectPathKey?: string): Promise { + const response = await this.request("terminal.close", { + session_id: sessionId, + project_path_key: projectPathKey, + }); + if (!response.session) { + throw new Error("Terminal response did not include a session"); + } + return normalizeTerminalSession(response.session); + } + + async closeProjectTerminals(projectPathKey: string): Promise { + const response = await this.request("terminal.close_project", { + project_path_key: projectPathKey, + }); + return (response.sessions ?? []).map(normalizeTerminalSession); + } + + async createTunnel(input: TunnelCreateInput): Promise { + const payload: Record = { + target_url: input.targetUrl, + ttl_seconds: input.ttlSeconds, + }; + const name = input.name?.trim(); + if (name) { + payload.name = name; + } + const projectPathKey = input.projectPathKey?.trim(); + if (projectPathKey) { + payload.project_path_key = projectPathKey; + } + await this.request("tunnel.create", payload); + } + + async updateTunnel(input: TunnelUpdateInput): Promise { + const payload: Record = { + tunnel_id: input.id, + target_url: input.targetUrl, + }; + // Omitting ttl_seconds keeps the current expiry; sending it re-buckets + // the expiry from now. + if (input.ttlSeconds !== undefined) { + payload.ttl_seconds = input.ttlSeconds; + } + const name = input.name?.trim(); + if (name) { + payload.name = name; + } + const projectPathKey = input.projectPathKey?.trim(); + if (projectPathKey) { + payload.project_path_key = projectPathKey; + } + await this.request("tunnel.update", payload); + } + + async closeTunnel(id: string): Promise { + await this.request("tunnel.close", { tunnel_id: id }); + } + + async checkTunnel(id?: string): Promise { + const payload: Record = {}; + const tunnelId = id?.trim(); + if (tunnelId) { + payload.tunnel_id = tunnelId; + } + await this.request("tunnel.check", payload); + } + + async listHistory( + page: number, + pageSize: number, + filter?: HistoryListFilter, + ): Promise { + const payload: { page: number; page_size: number; cwd?: string; cwd_empty?: boolean } = { + page: normalizeHistoryListPage(page), + page_size: normalizeHistoryListPageSize(pageSize), + }; + const cwd = filter?.cwd?.trim(); + if (cwd) { + payload.cwd = cwd; + } + if (filter?.cwdEmpty === true) { + payload.cwd_empty = true; + } + // running_conversations 由 chat_activities 帧提供:并行取回合并,返回形状不变。 + const [list, runningConversations] = await Promise.all([ + this.requestWithRecovery("history.list", payload), + this.listChatActivities().catch(() => [] as RunningConversationSummary[]), + ]); + return { ...list, running_conversations: runningConversations }; + } + + async listHistoryWorkdirs(): Promise { + const response = await this.requestWithRecovery<{ + workdirs?: Array<{ path?: string; conversation_count?: number; updated_at?: number }>; + }>("history.workdirs", {}); + return { + workdirs: (response.workdirs ?? []).map((item) => ({ + path: item.path ?? "", + conversationCount: item.conversation_count ?? 0, + updatedAt: item.updated_at ?? 0, + })), + }; + } + + async listSharedHistory(page: number, pageSize: number): Promise { + return this.requestWithRecovery("history.shared_list", { + page: normalizeHistoryListPage(page), + page_size: normalizeHistoryListPageSize(pageSize), + }); + } + + async getHistory(conversationId: string, options?: HistoryGetOptions): Promise { + return this.requestWithRecovery("history.get", { + conversation_id: conversationId, + max_messages: options?.maxMessages, + }); + } + + async getHistoryPrefix( + conversationId: string, + baseMessageRef: HistoryMessageRef, + options?: HistoryGetOptions, + ): Promise { + return this.requestWithRecovery("history.prefix", { + conversation_id: conversationId, + max_messages: options?.maxMessages, + base_message_ref: buildHistoryMessageRefPayload(baseMessageRef), + }); + } + + async renameHistory(conversationId: string, title: string): Promise { + return this.request("history.rename", { + conversation_id: conversationId, + title, + }); + } + + async branchHistory( + conversationId: string, + baseMessageRef: HistoryMessageRef, + ): Promise { + return this.request("history.branch", { + conversation_id: conversationId, + base_message_ref: buildHistoryMessageRefPayload(baseMessageRef), + }); + } + + async pinHistory(conversationId: string, isPinned: boolean): Promise { + return this.request("history.pin", { + conversation_id: conversationId, + is_pinned: isPinned, + }); + } + + async setHistoryCwd(conversationId: string, cwd: string): Promise { + return this.request("history.set_cwd", { + conversation_id: conversationId, + cwd, + }); + } + + async getHistoryShare(conversationId: string): Promise { + return this.requestWithRecovery("history.share.get", { + conversation_id: conversationId, + }); + } + + async setHistoryShare( + conversationId: string, + enabled: boolean, + options?: { redactToolContent?: boolean }, + ): Promise { + const payload: Record = { + conversation_id: conversationId, + enabled, + }; + if (typeof options?.redactToolContent === "boolean") { + payload.redact_tool_content = options.redactToolContent; + } + return this.request("history.share.set", payload); + } + + async deleteHistory(conversationId: string): Promise { + await this.request("history.delete", { + conversation_id: conversationId, + }); + } + + async listProviders(): Promise { + return this.requestWithRecovery("providers.list", {}); + } + + async getSettings(): Promise { + return this.requestWithRecovery("settings.get", {}); + } + + async updateSettings(payload: GatewaySettingsSyncUpdatePayload): Promise { + const response = await this.request("settings.update", payload); + if (response?.accepted === false) { + throw new Error( + response.message?.trim() || "SSH 设置已在另一端更新,已刷新为最新状态,请重新提交。", + ); + } + } + + async resetSshKnownHost(params: { + host: string; + port: number; + }): Promise { + return this.request("settings.ssh_known_host.reset", { + host: params.host, + port: params.port, + }); + } + + async listSkillFiles(): Promise { + return this.requestWithRecovery("skills.list", {}); + } + + async manageSkill(payload: SkillManagePayload): Promise { + return this.request("skills.manage", payload); + } + + async listMentionFiles( + workdir: string, + maxResults?: number, + query?: string, + showHidden?: boolean, + ): Promise { + return this.requestWithRecovery("mentions.list", { + workdir, + max_results: maxResults, + query, + show_hidden: showHidden, + }); + } + + async listFsRoots(): Promise { + return this.requestWithRecovery("fs.roots", {}); + } + + async listDirs(path: string, maxResults?: number): Promise { + return this.requestWithRecovery("fs.list_dirs", { + path, + max_results: maxResults, + }); + } + + async createProjectFolder(parent: string, name: string): Promise { + return this.request("fs.create_project_folder", { + parent, + name, + }); + } + + async listFiles( + workdir: string, + path?: string, + depth?: number, + offset?: number, + maxResults?: number, + showHidden?: boolean, + ): Promise { + return this.requestWithRecovery("fs.list", { + workdir, + path, + depth, + offset, + max_results: maxResults, + show_hidden: showHidden, + }); + } + + async writeTextFile(params: { + workdir: string; + path: string; + content: string; + mode?: string; + expectedMtimeMs?: number; + expectedContentHash?: string; + }): Promise { + return this.request("fs.write_text", { + workdir: params.workdir, + path: params.path, + content: params.content, + mode: params.mode ?? "rewrite", + expected_mtime_ms: params.expectedMtimeMs, + expected_content_hash: params.expectedContentHash, + }); + } + + async readEditableTextFile(workdir: string, path: string): Promise { + return this.request("fs.read_editable_text", { + workdir, + path, + }); + } + + async readWorkspaceImageFile( + workdir: string, + path: string, + ): Promise { + return this.request("fs.read_workspace_image", { + workdir, + path, + }); + } + + async openChatFile(params: { + conversationId: string; + workdir: string; + path: string; + source: string; + line?: number; + endLine?: number; + column?: number; + openInFileManager?: boolean; + }): Promise { + return this.request("chat.file_open", { + conversation_id: params.conversationId, + workdir: params.workdir, + path: params.path, + source: params.source, + line: params.line, + end_line: params.endLine, + column: params.column, + open_in_file_manager: params.openInFileManager, + }); + } + + async createDir(workdir: string, path: string): Promise { + return this.request("fs.create_dir", { workdir, path }); + } + + async renamePath(workdir: string, fromPath: string, toPath: string): Promise { + return this.request("fs.rename", { + workdir, + from_path: fromPath, + to_path: toPath, + }); + } + + async deletePath(workdir: string, path: string): Promise { + return this.request("fs.delete", { workdir, path }); + } + + async readUploadedImagePreview( + workdir: string, + absolutePath: string, + ): Promise { + return this.requestWithRecovery("files.preview", { + workdir, + absolute_path: absolutePath, + }); + } + + async readSkillMetadata(path: string): Promise { + return this.requestWithRecovery("skills.read-metadata", { path }); + } + + async readSkillText(path: string, offset?: number, length?: number): Promise { + return this.requestWithRecovery("skills.read-text", { + path, + offset, + length, + }); + } + + async getProviderModels( + type: string, + baseUrl: string, + apiKey: string, + useSystemProxy = false, + modelsUrl = "", + ): Promise { + return this.requestWithRecovery("provider.models", { + type, + base_url: baseUrl, + api_key: apiKey, + use_system_proxy: useSystemProxy, + models_url: modelsUrl, + }); + } + + async providerUsageQuery(providerId: string, refresh: boolean): Promise { + return this.requestWithRecovery("provider.usage.query", { + provider_id: providerId, + refresh, + }); + } + + async providerUsageTest(providerId: string, configJson: string): Promise { + // 按草稿测试:config_json 非空时桌面端忽略启用开关、不落库不进缓存。 + return this.requestWithRecovery("provider.usage.query", { + provider_id: providerId, + refresh: true, + config_json: configJson, + }); + } + + dispose() { + this.disposed = true; + this.terminalStream.dispose(); + this.uninstallReconnectWakeups(); + this.clearReconnectTimer(); + this.clearReconnectNoticeTimer(); + this.stopStatusPolling(); + this.handleDisconnect(new Error("Gateway WebSocket client disposed")); + } + + private startStatusPolling() { + this.stopStatusPolling(); + this.statusPollingActive = true; + void this.refreshStatus(); + this.scheduleNextStatusPoll(); + } + + private stopStatusPolling() { + this.statusPollingActive = false; + if (this.statusPollTimer !== null) { + const host = getRuntimeHost(); + host.clearTimeout(this.statusPollTimer); + this.statusPollTimer = null; + } + } + + private scheduleNextStatusPoll() { + if (this.disposed || !this.statusPollingActive || this.statusPollTimer !== null) { + return; + } + const host = getRuntimeHost(); + const delay = + this.lastStatus !== null && this.lastStatus.online === false + ? STATUS_POLL_OFFLINE_INTERVAL_MS + : STATUS_POLL_INTERVAL_MS; + this.statusPollTimer = host.setTimeout(() => { + this.statusPollTimer = null; + void this.refreshStatus().finally(() => this.scheduleNextStatusPoll()); + }, delay); + } + + private installReconnectWakeups() { + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("online", this.reconnectWakeup); + window.addEventListener("focus", this.reconnectWakeup); + window.addEventListener("pageshow", this.reconnectWakeup); + window.addEventListener("pagehide", this.reconnectWakeup); + } + if (typeof document !== "undefined" && typeof document.addEventListener === "function") { + document.addEventListener("visibilitychange", this.reconnectWakeup); + document.addEventListener("freeze", this.reconnectWakeup as EventListener); + document.addEventListener("resume", this.reconnectWakeup as EventListener); + } + } + + private uninstallReconnectWakeups() { + if (typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("online", this.reconnectWakeup); + window.removeEventListener("focus", this.reconnectWakeup); + window.removeEventListener("pageshow", this.reconnectWakeup); + window.removeEventListener("pagehide", this.reconnectWakeup); + } + if (typeof document !== "undefined" && typeof document.removeEventListener === "function") { + document.removeEventListener("visibilitychange", this.reconnectWakeup); + document.removeEventListener("freeze", this.reconnectWakeup as EventListener); + document.removeEventListener("resume", this.reconnectWakeup as EventListener); + } + } + + private clearReconnectTimer() { + if (this.reconnectTimer !== null) { + const host = getRuntimeHost(); + host.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private clearReconnectNoticeTimer() { + if (this.reconnectNoticeTimer !== null) { + const host = getRuntimeHost(); + host.clearTimeout(this.reconnectNoticeTimer); + this.reconnectNoticeTimer = null; + } + } + + private scheduleReconnectNotice() { + if (this.reconnectNoticeTimer !== null || this.disposed || this.statusListeners.size === 0) { + return; + } + const host = getRuntimeHost(); + this.reconnectNoticeTimer = host.setTimeout(() => { + this.reconnectNoticeTimer = null; + if ( + this.disposed || + this.statusListeners.size === 0 || + (this.socket?.readyState === WebSocket.OPEN && this.authenticated) + ) { + return; + } + if (isDocumentHidden()) { + // Deferred: the wakeup handler re-arms this notice and only a failed + // post-wake reconnect gets to paint the offline banner. + this.offlineReassessmentPending = true; + return; + } + this.emitStatus( + this.lastStatus + ? { + ...this.lastStatus, + online: false, + } + : null, + "Gateway 正在重新连接...", + ); + }, RECONNECT_NOTICE_DELAY_MS); + } + + private shouldMaintainConnection() { + return ( + !this.disposed && + this.token.trim() !== "" && + (this.pending.size > 0 || + this.statusListeners.size > 0 || + this.historyListeners.size > 0 || + this.settingsListeners.size > 0 || + this.terminalListeners.size > 0 || + this.sftpTransferListeners.size > 0 || + this.chatActivityListeners.size > 0 || + this.tunnelStateListeners.size > 0 || + this.processStateListeners.size > 0 || + this.workspaceActivityListeners.size > 0 || + this.conversationStreams.size > 0) + ); + } + + private scheduleReconnect(delayMs?: number) { + if (!this.shouldMaintainConnection()) { + return; + } + // 鉴权已被服务端拒绝:坏 token 的自动重连只会撞上同一个 4401。 + if (this.authRejected) { + return; + } + if (this.socket?.readyState === WebSocket.OPEN && this.authenticated) { + return; + } + if (this.connectPromise || this.reconnecting || this.reconnectTimer !== null) { + return; + } + + const baseDelay = + typeof delayMs === "number" + ? delayMs + : Math.min( + RECONNECT_MAX_DELAY_MS, + RECONNECT_INITIAL_DELAY_MS * 2 ** Math.min(this.reconnectAttempt, 5), + ); + const jitter = + baseDelay > 0 ? Math.floor(Math.random() * Math.min(500, Math.max(1, baseDelay))) : 0; + const host = getRuntimeHost(); + this.reconnectTimer = host.setTimeout(() => { + this.reconnectTimer = null; + void this.reconnectNow(); + }, baseDelay + jitter); + } + + private async reconnectNow() { + if (!this.shouldMaintainConnection()) { + return; + } + if (this.socket?.readyState === WebSocket.OPEN && this.authenticated) { + return; + } + if (this.reconnecting) { + return; + } + + this.reconnecting = true; + try { + await this.ensureConnected(); + this.clearReconnectNoticeTimer(); + this.reconnectAttempt = 0; + if (this.statusListeners.size > 0) { + void this.refreshStatus(); + } + } catch { + this.reconnectAttempt += 1; + this.scheduleReconnect(); + } finally { + this.reconnecting = false; + if ( + this.shouldMaintainConnection() && + !(this.socket?.readyState === WebSocket.OPEN && this.authenticated) + ) { + this.scheduleReconnect(); + } + } + } + + // The transport is demonstrably alive while frames keep arriving; a starved + // status.get during a chat.event burst must not flip the UI to offline. + private hasFreshInboundActivity() { + return ( + this.socket !== null && + this.authenticated && + this.socket.readyState === WebSocket.OPEN && + this.lastInboundAt > 0 && + Date.now() - this.lastInboundAt < SOCKET_INBOUND_STALL_MS + ); + } + + private async refreshStatus() { + if (this.statusRefreshInFlight) { + return; + } + this.statusRefreshInFlight = true; + try { + const status = await this.getStatus(); + this.clearReconnectNoticeTimer(); + this.emitStatus(status, null); + } catch (error) { + if (isRecoverableGatewayTransportError(error) && this.shouldMaintainConnection()) { + this.scheduleReconnect(); + this.scheduleReconnectNotice(); + return; + } + if (isRequestTimeoutError(error) && this.hasFreshInboundActivity()) { + // Keep the last known status; the next poll retries. + return; + } + if (isDocumentHidden()) { + // Keep the last visible status; the foreground wakeup rechecks + // before any offline verdict is painted. + this.offlineReassessmentPending = true; + return; + } + const message = asErrorMessage(error, "status request failed"); + const offlineStatus = + this.lastStatus !== null + ? { + ...this.lastStatus, + online: false, + } + : null; + this.lastStatus = offlineStatus; + this.lastStatusError = message; + this.emitStatus(offlineStatus, message); + } finally { + this.statusRefreshInFlight = false; + } + } + + private emitStatus(status: AgentStatus | null, error: string | null) { + // Whatever gets painted is the verdict; any deferred reassessment is moot. + this.offlineReassessmentPending = false; + this.lastStatus = status; + this.lastStatusError = error; + if (status?.online === false) { + this.terminalSessionSnapshot.clear(); + // Switch a pending slow poll to the offline fast cadence right away + // rather than after its remaining (up to 30s) delay. + if (this.statusPollTimer !== null) { + const host = getRuntimeHost(); + host.clearTimeout(this.statusPollTimer); + this.statusPollTimer = null; + this.scheduleNextStatusPoll(); + } + } + for (const listener of this.statusListeners) { + listener(status, error); + } + } + + private emitHistory(event: GatewayHistoryEvent) { + for (const listener of this.historyListeners) { + listener(event); + } + } + + private emitSettings(event: GatewaySettingsSyncPayload) { + for (const listener of this.settingsListeners) { + listener(event); + } + } + + private emitTerminal(event: TerminalEvent) { + applyTerminalSnapshotEvent(this.terminalSessionSnapshot, event); + for (const listener of this.terminalListeners) { + listener(event); + } + } + + private emitSftpTransfer(event: SftpTransferEvent) { + for (const listener of this.sftpTransferListeners) { + listener(event); + } + } + + // No server-revision guard here: managed-process revisions are stamped by + // the desktop agent and persisted across restarts; the mirrored store does + // its own monotonic filtering. + private emitProcessState(state: ManagedProcessStatePayload) { + this.lastProcessState = state; + for (const listener of this.processStateListeners) { + listener(state); + } + } + + private emitTunnelState(snapshot: TunnelStateSnapshot) { + // Drop stale/reordered snapshots from the current gateway process. + if (snapshot.revision <= this.lastTunnelStateServerRevision) { + return; + } + this.lastTunnelStateServerRevision = snapshot.revision; + this.tunnelStateRevisionCounter += 1; + const next: TunnelStateSnapshot = { + ...snapshot, + revision: this.tunnelStateRevisionCounter, + }; + this.lastTunnelState = next; + for (const listener of this.tunnelStateListeners) { + listener(next); + } + } + + private async requestWithRecovery( + type: string, + payload: unknown, + options?: GatewayRequestOptions, + ): Promise { + try { + return await this.request(type, payload, options); + } catch (error) { + if (!isRecoverableGatewayTransportError(error) || this.disposed) { + throw error; + } + await this.recoverTransport(); + return this.request(type, payload, options); + } + } + + private async request( + type: string, + payload: unknown, + options?: GatewayRequestOptions, + ): Promise { + await this.ensureConnected(); + let agentId = this.activeAgentId; + if (requestRequiresAgentId(type)) { + agentId = await this.ensureActiveAgent(); + // 首次自动选中会主动重连,以便只回放目标 Agent 的快照。 + await this.ensureConnected(); + } + return this.sendConnectedRequest(type, payload, options, agentId); + } + + private sendConnectedRequest( + type: string, + payload: unknown, + options: GatewayRequestOptions | undefined, + agentId: string, + ): Promise { + const requestId = this.nextRequestId(type); + return new Promise((resolve, reject) => { + const host = getRuntimeHost(); + const requestStartedAt = Date.now(); + const timeoutId = host.setTimeout(() => { + const pending = this.pending.get(requestId); + if (!pending) { + return; + } + + if (this.shouldTreatRequestTimeoutAsTransportStall(requestStartedAt)) { + this.handleDisconnect(this.buildTransportStallError(`while waiting for ${type}`)); + return; + } + + this.pending.delete(requestId); + reject(new Error(`Gateway WebSocket request timed out: ${type}`)); + }, options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS); + + this.pending.set(requestId, { + resolve, + reject, + timeoutId, + }); + + try { + this.sendFrame(encodeRequestFrame(requestId, type, payload, agentId)); + } catch (error) { + host.clearTimeout(timeoutId); + this.pending.delete(requestId); + reject(error); + } + }); + } + + private async ensureConnected(): Promise { + if (this.disposed) { + throw new Error("Gateway WebSocket client has been disposed"); + } + if (this.token.trim() === "") { + throw new Error("Gateway token is required"); + } + if (this.socket && this.authenticated && this.socket.readyState === WebSocket.OPEN) { + if (this.shouldRecycleAuthenticatedSocket()) { + this.handleDisconnect(this.buildTransportStallError("before sending a new request")); + } else { + return; + } + } + if (this.connectPromise) { + await this.connectPromise; + return; + } + + const socketUrl = buildWebSocketUrl(); + let reconnectAfterTimeout = false; + this.connectPromise = new Promise((resolve, reject) => { + // 显式连接尝试清除鉴权失败标记;自动重连循环不会反复敲门坏 token。 + this.authRejected = false; + const socket = new WebSocket(socketUrl, GATEWAY_V2_SUBPROTOCOL); + socket.binaryType = "arraybuffer"; + const host = getRuntimeHost(); + let settled = false; + let authTimeoutId: number | null = null; + const clearAttemptTimers = () => { + host.clearTimeout(connectionTimeoutId); + if (authTimeoutId !== null) { + host.clearTimeout(authTimeoutId); + authTimeoutId = null; + } + }; + const rejectOnce = (error: Error) => { + clearAttemptTimers(); + if (settled) { + return; + } + settled = true; + reject(error); + }; + const connectionTimeoutId = host.setTimeout(() => { + const error = new Error("Gateway WebSocket connection timed out"); + reconnectAfterTimeout = true; + rejectOnce(error); + if (this.socket === socket) { + this.handleDisconnect(error); + return; + } + socket.onopen = null; + socket.onclose = null; + socket.onmessage = null; + socket.onerror = null; + try { + socket.close(); + } catch { + // The browser may reject close() while the handshake is between + // internal states; the detached attempt is still abandoned. + } + }, SOCKET_CONNECT_TIMEOUT_MS); + + socket.onmessage = (event) => { + this.handleMessage(event.data); + }; + socket.onerror = () => { + // onclose will drive the actual error propagation + }; + socket.onclose = (event) => { + clearAttemptTimers(); + const reason = event.reason.trim() ? ` reason=${event.reason.trim()}` : ""; + const error = new Error( + `Gateway WebSocket disconnected (code=${event.code} clean=${event.wasClean}${reason})`, + ); + // A rejected attempt can close after its caller has already opened a + // replacement socket. Only the socket still owned by this client may + // tear down shared state; a detached attempt merely settles itself. + if (this.socket === socket) { + this.handleDisconnect(error); + } + rejectOnce(error); + }; + socket.onopen = () => { + if (settled) { + socket.close(); + return; + } + // The transport phase is over: hand the deadline to the auth timer. + // Left running, the connect timer would always preempt the (longer) + // auth timeout and cap connect+auth at the connect budget. + host.clearTimeout(connectionTimeoutId); + this.socket = socket; + this.authenticated = false; + this.lastInboundAt = 0; + this.clearReconnectTimer(); + + const authId = this.nextRequestId("auth"); + authTimeoutId = host.setTimeout(() => { + this.pending.delete(authId); + const error = new Error("Gateway WebSocket auth timed out"); + rejectOnce(error); + if (this.socket === socket) { + this.handleDisconnect(error); + } else { + socket.close(); + } + }, SOCKET_AUTH_TIMEOUT_MS); + + this.pending.set(authId, { + resolve: () => { + clearAttemptTimers(); + this.authenticated = true; + this.clearReconnectNoticeTimer(); + this.reconnectAttempt = 0; + this.setConnectionState(true); + if (this.activeAgentId) { + this.conversationStreams.handleConnected(); + this.handleWorkspaceActivityConnected(); + } else { + void this.ensureActiveAgent().catch(() => { + // 没有可选 Agent 时由具体业务请求展示错误。 + }); + } + if (!settled) { + settled = true; + resolve(); + } + }, + reject: (reason) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + rejectOnce(error); + if (this.socket === socket) { + this.handleDisconnect(error); + } else { + socket.onclose = null; + socket.close(); + } + }, + timeoutId: authTimeoutId, + }); + + this.sendFrame(encodeHelloFrame(authId, this.token)); + }; + }).finally(() => { + this.connectPromise = null; + if (reconnectAfterTimeout && !this.disposed) { + this.scheduleReconnect(0); + } + }); + + await this.connectPromise; + } + + // sendFrame 发送一帧已编码的 v2 二进制帧。 + private sendFrame(frame: Uint8Array) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error("Gateway WebSocket is not connected"); + } + this.socket.send(frame); + } + + private handleMessage(raw: unknown) { + this.markInboundActivity(); + // v2 链路只承载二进制帧;文本帧仅计入存活。 + if (!(raw instanceof ArrayBuffer) && !ArrayBuffer.isView(raw)) { + return; + } + let decoded: DecodedServerFrame | null; + try { + decoded = decodeServerFrame( + decodeServerFrameBinary(raw instanceof ArrayBuffer ? raw : (raw as Uint8Array)), + { agentOnline: this.lastStatus?.online === true }, + ); + } catch { + return; + } + if (!decoded) { + return; + } + + if (decoded.kind === "hello") { + const pending = this.pending.get(decoded.requestId); + if (!pending) { + return; + } + const host = getRuntimeHost(); + host.clearTimeout(pending.timeoutId); + this.pending.delete(decoded.requestId); + if (decoded.ok) { + pending.resolve({ ok: true }); + } else { + // 服务端将以 4401 关闭:标记鉴权失败并阻断自动重连,交由上层展示鉴权错误。 + this.authRejected = true; + pending.reject(new Error(decoded.message || "unauthorized")); + } + return; + } + + if (decoded.kind === "ping") { + // 应用层心跳:回送 protobuf pong。 + try { + this.sendFrame(encodePongFrame(decoded.timestamp || Date.now())); + } catch { + // The close handler will drive reconnect if the socket is already gone. + } + return; + } + + if (decoded.kind === "event") { + if (decoded.type === "status.event" && decoded.agentId) { + // 打标状态帧始终更新目录;业务监听只接收当前明确目标。 + const nextStatus = decoded.payload as AgentStatus; + const name = this.agents.get(decoded.agentId)?.name?.trim(); + this.agents.set(decoded.agentId, name ? { ...nextStatus, name } : nextStatus); + this.emitAgents(); + } + if (decoded.agentId && !this.activeAgentId) { + this.bufferAgentEvent(decoded.agentId, decoded.type, decoded.payload); + return; + } + if (decoded.agentId && !this.matchesActiveAgent(decoded.agentId)) { + return; + } + this.handleEvent(decoded.type, decoded.payload); + return; + } + + const pending = decoded.requestId ? this.pending.get(decoded.requestId) : null; + if (!pending) { + return; + } + const host = getRuntimeHost(); + host.clearTimeout(pending.timeoutId); + this.pending.delete(decoded.requestId); + if (decoded.kind === "error") { + pending.reject(new Error(decoded.message || "Request failed")); + return; + } + pending.resolve(decoded.payload); + } + + // 分发广播事件;payload 已由适配层还原为既有归一化器需要的对象形状。 + private handleEvent(type: string, payload: unknown) { + if (type === "history.event") { + const event = payload as GatewayHistoryEvent; + if (event?.kind === "upsert" || event?.kind === "delete") { + this.emitHistory(event); + } + return; + } + + if (type === "settings.event") { + const event = payload as GatewaySettingsSyncPayload; + if (event && typeof event === "object") { + this.emitSettings(event); + } + return; + } + + if (type === "terminal.event") { + const event = normalizeTerminalEvent( + payload as RawTerminalEvent, + normalizeSshLocalForwardEvent, + ); + if (event) { + this.emitTerminal(event); + } + return; + } + + if (type === "sftp.event") { + const event = normalizeSftpTransferEvent(payload as RawSftpTransferEvent); + if (event) { + this.emitSftpTransfer(event); + } + return; + } + + if (type === "process.state") { + const statePayload = + payload && typeof payload === "object" ? (payload as RawManagedProcessStatePayload) : null; + if (statePayload) { + this.emitProcessState(normalizeManagedProcessState(statePayload)); + } + return; + } + + if (type === "tunnel.state") { + const statePayload = + payload && typeof payload === "object" ? (payload as RawTunnelStatePayload) : null; + if (statePayload) { + this.emitTunnelState(normalizeTunnelStateSnapshot(statePayload)); + } + return; + } + + if (type === "status.event") { + const statusPayload = + payload && typeof payload === "object" ? (payload as AgentStatus) : null; + if (statusPayload && typeof statusPayload.online === "boolean") { + this.clearReconnectNoticeTimer(); + this.emitStatus(statusPayload, null); + } + return; + } + + if (type === "workspace.activity") { + const activityPayload = + payload && typeof payload === "object" ? (payload as RawWorkspaceActivityPayload) : null; + const activity = activityPayload ? normalizeWorkspaceActivity(activityPayload) : null; + if (activity) { + this.emitWorkspaceActivity(activity); + } + return; + } + + if (type === "chat_queue.event") { + const snapshot = normalizeChatQueueEvent(payload as RawChatQueueEvent); + if (snapshot) { + this.emitChatQueue(snapshot); + } + return; + } + + if (type === "chat.event") { + this.conversationStreams.handleChatEvent(payload); + return; + } + + if (type === "chat.subscription_reset") { + this.conversationStreams.handleSubscriptionReset(payload); + return; + } + + if (type === "chat.activity") { + const event = normalizeActivityEvent(payload); + if (event) { + for (const listener of this.chatActivityListeners) { + listener(event); + } + } + return; + } + + if (type === "chat.command_update") { + const update = normalizeCommandUpdate(payload); + if (update) { + for (const listener of this.chatCommandUpdateListeners) { + listener(update); + } + } + } + } + + private handleDisconnect(error: Error) { + if (this.socket) { + this.socket.onclose = null; + this.socket.onmessage = null; + this.socket.onerror = null; + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { + this.socket.close(); + } + } + this.socket = null; + this.authenticated = false; + this.pendingAgentEvents.clear(); + this.setConnectionState(false); + + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const entry of pending) { + const host = getRuntimeHost(); + host.clearTimeout(entry.timeoutId); + entry.reject(error); + } + + // Conversation stream registrations survive the disconnect; they resume + // with their seq cursors when the socket re-authenticates. + this.conversationStreams.handleDisconnected(); + + if (!this.disposed && this.statusListeners.size > 0) { + if (isRecoverableGatewayTransportError(error) && this.shouldMaintainConnection()) { + this.scheduleReconnectNotice(); + } else if (isDocumentHidden()) { + this.offlineReassessmentPending = true; + } else { + this.emitStatus( + this.lastStatus + ? { + ...this.lastStatus, + online: false, + } + : null, + error.message, + ); + } + } + this.lastInboundAt = 0; + // A reconnect may land on a restarted gateway whose tunnel.state + // revisions start over; accept the fresh post-auth snapshot. + this.lastTunnelStateServerRevision = 0; + if (!this.disposed) { + this.scheduleReconnect(); + } + } + + private markInboundActivity() { + this.lastInboundAt = Date.now(); + } + + private setConnectionState(connected: boolean) { + if (this.connectionState === connected) { + return; + } + this.connectionState = connected; + for (const listener of this.connectionListeners) { + listener(connected); + } + } + + private emitChatQueue(snapshot: ChatQueueSnapshot) { + for (const listener of this.chatQueueListeners) { + listener(snapshot); + } + } + + private shouldRecycleAuthenticatedSocket(now = Date.now()) { + if ( + this.socket === null || + !this.authenticated || + this.socket.readyState !== WebSocket.OPEN || + this.lastInboundAt <= 0 + ) { + return false; + } + if (now - this.lastInboundAt >= SOCKET_INBOUND_STALL_MS) { + return true; + } + return ( + this.lastForegroundWakeupAt > 0 && + now - this.lastForegroundWakeupAt <= FOREGROUND_WAKEUP_RECENCY_MS && + this.lastInboundAt < this.lastForegroundWakeupAt && + now - this.lastInboundAt >= FOREGROUND_SOCKET_RECYCLE_IDLE_MS + ); + } + + private shouldTreatRequestTimeoutAsTransportStall(requestStartedAt: number) { + return ( + this.socket !== null && + this.authenticated && + this.socket.readyState === WebSocket.OPEN && + this.lastInboundAt <= requestStartedAt + ); + } + + private buildTransportStallError(reason: string) { + return new Error(`Gateway transport stalled ${reason}`.trim()); + } + + private async recoverTransport() { + await this.ensureConnected(); + } + + private nextRequestId(prefix: string) { + this.requestSeq += 1; + return `${prefix}-${Date.now()}-${this.requestSeq}`; + } +} + +export type GatewayWebSocketClientLike = { + terminalStream: TerminalStreamClient; + getStatus(): Promise; + prepareChatRuntime(reason?: string): Promise; + // 多 Agent 寻址:活跃 Agent 选择、目录查询与目录订阅。 + getActiveAgent(): string; + setActiveAgent(agentId: string): void; + listAgents(): Promise; + subscribeAgents(listener: (agents: AgentStatus[]) => void): () => void; + subscribeStatus(listener: StatusListener): () => void; + subscribeHistory(listener: HistoryListener): () => void; + subscribeSettings(listener: SettingsListener): () => void; + subscribeTerminal(listener: TerminalListener): () => void; + subscribeSftpTransfers(listener: SftpTransferListener): () => void; + subscribeChatQueue(listener: ChatQueueListener): () => void; + subscribeChatActivity(listener: ChatActivityListener): () => void; + subscribeChatCommandUpdates(listener: ChatCommandUpdateListener): () => void; + subscribeConnection(listener: (connected: boolean) => void): () => void; + listChatActivities(): Promise; + resyncConversation(conversationId: string): void; + subscribeConversationStream( + conversationId: string, + handlers: ConversationStreamHandlers, + ): () => void; + chatCommand(input: GatewayChatCommandInput): Promise; + cancelChat(conversationId: string, runId?: string): Promise; + chatQueueGet(conversationId: string): Promise; + chatQueueGetItem(conversationId: string, itemId: string): Promise; + chatQueueRunNow(conversationId: string, itemId: string): Promise; + chatQueueCompactNow(conversationId: string, operationId: string): Promise; + chatQueueMove( + conversationId: string, + itemId: string, + direction: "up" | "down", + ): Promise; + chatQueueRemove(conversationId: string, itemId: string): Promise; + chatQueueEditBegin(conversationId: string, itemId: string): Promise; + chatQueueEditCommit(input: { + conversationId: string; + itemId: string; + revision: number; + draftJson: string; + uploadedFilesJson: string; + }): Promise; + chatQueueEditCancel(conversationId: string, itemId: string): Promise; + cronManage(payload: CronManagePayload): Promise; + memoryManage(payload: MemoryManagePayload): Promise; + gitRequest( + action: string, + workdir: string, + args?: Record, + ): Promise; + sftpList(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path?: string; + }): Promise; + sftpStat(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path?: string; + }): Promise; + sftpMkdir(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path: string; + }): Promise; + sftpRename(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + fromPath: string; + toPath: string; + }): Promise; + sftpDelete(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + side: "local" | "remote"; + path: string; + recursive?: boolean; + }): Promise; + sftpTransfer(params: { + sessionId: string; + projectPathKey: string; + workdir: string; + direction: "upload" | "download"; + sourcePath: string; + targetPath: string; + recursive?: boolean; + overwrite?: boolean; + }): Promise; + sftpCancelTransfer(params: { sessionId: string; transferId: string }): Promise; + terminalShellOptions(): Promise; + listTerminals(projectPathKey?: string): Promise; + createTerminal(params: { + cwd: string; + projectPathKey: string; + shell?: string; + title?: string; + cols?: number; + rows?: number; + }): Promise; + createSshTerminal(params: { + cwd: string; + projectPathKey: string; + hostId: string; + title?: string; + cols?: number; + rows?: number; + sftpEnabled?: boolean; + }): Promise; + answerSshTerminalPrompt(params: { + promptId: string; + answer?: string; + trustHostKey?: boolean; + }): Promise; + cancelSshTerminalPrompt(promptId: string): Promise; + reconnectSshTerminal(sessionId: string, projectPathKey?: string): Promise; + sshTerminalLatency(sessionId: string, projectPathKey?: string): Promise; + listSshTerminalTabs(projectPathKey: string): Promise; + openSshTerminalTab(params: { + sessionId: string; + kind: SshTerminalTabKind; + }): Promise; + closeSshTerminalTab(tabId: string): Promise; + listSshLocalForwards(params?: { + sessionId?: string; + projectPathKey?: string; + }): Promise; + startSshLocalForward(params: { + sessionId: string; + projectPathKey?: string; + remoteHost: string; + remotePort: number; + localPort?: number; + }): Promise; + stopSshLocalForward(params: { + forwardId: string; + sessionId?: string; + }): Promise; + checkSshLocalForwardPort(localPort: number): Promise; + subscribeSshLocalForward(listener: (event: SshLocalForwardEvent) => void): () => void; + renameTerminal( + sessionId: string, + title: string, + projectPathKey?: string, + ): Promise; + closeTerminal(sessionId: string, projectPathKey?: string): Promise; + closeProjectTerminals(projectPathKey: string): Promise; + subscribeTunnelState(listener: (snapshot: TunnelStateSnapshot) => void): () => void; + subscribeProcessState(listener: (state: ManagedProcessStatePayload) => void): () => void; + processSnapshot(): Promise; + processStop(processId: string): Promise; + processClear(processId?: string): Promise; + processReadLog(processId: string, maxBytes?: number): Promise; + subscribeWorkspaceActivity( + workdir: string, + listener: (event: WorkspaceActivityEventPayload) => void, + ): () => void; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; + checkTunnel(id?: string): Promise; + listHistory(page: number, pageSize: number, filter?: HistoryListFilter): Promise; + listHistoryWorkdirs(): Promise; + listSharedHistory(page: number, pageSize: number): Promise; + getHistory(conversationId: string, options?: HistoryGetOptions): Promise; + getHistoryPrefix( + conversationId: string, + baseMessageRef: HistoryMessageRef, + options?: HistoryGetOptions, + ): Promise; + renameHistory(conversationId: string, title: string): Promise; + branchHistory( + conversationId: string, + baseMessageRef: HistoryMessageRef, + ): Promise; + pinHistory(conversationId: string, isPinned: boolean): Promise; + setHistoryCwd(conversationId: string, cwd: string): Promise; + getHistoryShare(conversationId: string): Promise; + setHistoryShare( + conversationId: string, + enabled: boolean, + options?: { redactToolContent?: boolean }, + ): Promise; + deleteHistory(conversationId: string): Promise; + listProviders(): Promise; + getSettings(): Promise; + updateSettings(payload: GatewaySettingsSyncUpdatePayload): Promise; + resetSshKnownHost(params: { host: string; port: number }): Promise; + listSkillFiles(): Promise; + manageSkill(payload: SkillManagePayload): Promise; + listMentionFiles( + workdir: string, + maxResults?: number, + query?: string, + showHidden?: boolean, + ): Promise; + listFsRoots(): Promise; + listDirs(path: string, maxResults?: number): Promise; + createProjectFolder(parent: string, name: string): Promise; + listFiles( + workdir: string, + path?: string, + depth?: number, + offset?: number, + maxResults?: number, + showHidden?: boolean, + ): Promise; + writeTextFile(params: { + workdir: string; + path: string; + content: string; + mode?: string; + expectedMtimeMs?: number; + expectedContentHash?: string; + }): Promise; + readEditableTextFile(workdir: string, path: string): Promise; + readWorkspaceImageFile(workdir: string, path: string): Promise; + openChatFile(params: { + conversationId: string; + workdir: string; + path: string; + source: string; + line?: number; + endLine?: number; + column?: number; + openInFileManager?: boolean; + }): Promise; + createDir(workdir: string, path: string): Promise; + renamePath(workdir: string, fromPath: string, toPath: string): Promise; + deletePath(workdir: string, path: string): Promise; + readUploadedImagePreview( + workdir: string, + absolutePath: string, + ): Promise; + readSkillMetadata(path: string): Promise; + readSkillText(path: string, offset?: number, length?: number): Promise; + getProviderModels( + type: string, + baseUrl: string, + apiKey: string, + useSystemProxy?: boolean, + modelsUrl?: string, + ): Promise; + providerUsageQuery(providerId: string, refresh: boolean): Promise; + providerUsageTest(providerId: string, configJson: string): Promise; + dispose(): void; +}; + +let activeClient: GatewayWebSocketClient | null = null; +let activeToken = ""; +let activeOrigin = ""; +// True once any client has existed this page lifetime. reset* nulls +// activeClient without notifying, so "activeClient !== null" alone would +// miss the reset→create sequence and leave module-scoped stores subscribed +// to the disposed instance forever. +let everHadClient = false; +const clientReplacedListeners = new Set<() => void>(); + +/** + * Fires after the singleton client has been replaced (token or gateway origin + * change). Module scoped stores that subscribed on the old instance re-attach + * here — a disposed client never delivers events again. + */ +export function onGatewayWebSocketClientReplaced(listener: () => void): () => void { + clientReplacedListeners.add(listener); + return () => { + clientReplacedListeners.delete(listener); + }; +} + +export function getGatewayWebSocketClient(token: string): GatewayWebSocketClient { + const normalizedToken = token.trim(); + const origin = getGatewayOrigin(); + if (activeClient && activeToken === normalizedToken && activeOrigin === origin) { + return activeClient; + } + const replaced = everHadClient; + activeClient?.dispose(); + activeToken = normalizedToken; + activeOrigin = origin; + activeClient = new GatewayWebSocketClient(normalizedToken); + everHadClient = true; + if (replaced) { + // The new instance is already installed, so re-entrant + // getGatewayWebSocketClient calls from listeners hit the fast path. + for (const listener of clientReplacedListeners) { + listener(); + } + } + return activeClient; +} + +export function resetGatewayWebSocketClient() { + activeClient?.dispose(); + activeClient = null; + activeToken = ""; + activeOrigin = ""; +} diff --git a/crates/agent-mobile/src/lib/gatewaySocketV2/adapters.ts b/crates/agent-mobile/src/lib/gatewaySocketV2/adapters.ts new file mode 100644 index 000000000..e36e79755 --- /dev/null +++ b/crates/agent-mobile/src/lib/gatewaySocketV2/adapters.ts @@ -0,0 +1,1870 @@ +// v2 线协议适配层:把 gatewaySocket 公开 API 使用的请求类型字符串与 snake_case +// UI 载荷编码为 protobuf 帧,并把服务端帧还原为现有归一化器需要的对象形状。 +// bigint 边界:本文件是 64 位整数(生成代码映射为 bigint)的唯一出入口——入站一律 Number()(均为 +// 时间戳/计数,远小于 2^53 无精度损失),出站 BigInt() 收窄;适配层之外不允许出现 bigint。 +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import type { + AgentEnvelope, + ChatQueueEvent, + ConversationSummary, + GatewayEnvelope, + HistoryShareStatus, + HistorySyncEvent, + ManagedProcessResponse, + ManagedProcessSnapshot, + SettingsSyncEvent, + SftpEntry, + SftpEvent, + SftpResponse, + SftpTransfer, + TerminalEvent, + TerminalResponse, + TerminalSession, + TerminalSshLocalForward, + TerminalSshLocalForwardAction, + TerminalSshLocalForwardsSnapshot, + TerminalSshTabsSnapshot, + TerminalStreamFrame, + TunnelHealth, + TunnelStateSnapshot, + WorkspaceActivityEvent, +} from "@/lib/proto/gen/proto/v2/gateway_pb"; +import { + CancelChatRequestSchema, + ChatCommandRequestSchema, + ChatFileOpenRequestSchema, + ChatMessageRefSchema, + ChatQueueRequestSchema, + ChatRequestSchema, + ChatRuntimeControlsSchema, + ChatSelectedModelSchema, + ChatUploadedFileSchema, + CronManageRequestSchema, + FileMentionListRequestSchema, + FsCreateDirRequestSchema, + FsCreateProjectFolderRequestSchema, + FsDeleteRequestSchema, + FsListDirsRequestSchema, + FsListRequestSchema, + FsReadEditableTextRequestSchema, + FsReadWorkspaceImageRequestSchema, + FsRenameRequestSchema, + FsRootsRequestSchema, + FsWriteTextRequestSchema, + GatewayEnvelopeSchema, + GitRequestSchema, + HistoryBranchRequestSchema, + HistoryDeleteRequestSchema, + HistoryGetRequestSchema, + HistoryListRequestSchema, + HistoryPinRequestSchema, + HistoryPrefixRequestSchema, + HistoryRenameRequestSchema, + HistorySetCwdRequestSchema, + HistoryShareGetRequestSchema, + HistoryShareSetRequestSchema, + HistoryWorkdirsRequestSchema, + ManagedProcessRequestSchema, + MemoryManageRequestSchema, + ProviderListRequestSchema, + ProviderModelsRequestSchema, + ProviderUsageRequestSchema, + SettingsGetRequestSchema, + SettingsResetSshKnownHostRequestSchema, + SettingsUpdateRequestSchema, + SftpRequestSchema, + SkillFilesListRequestSchema, + SkillManageRequestSchema, + SkillMetadataReadRequestSchema, + SkillTextReadRequestSchema, + TerminalRequestSchema, + TerminalStreamFrameSchema, + TunnelMutationSchema, + UploadedImagePreviewRequestSchema, +} from "@/lib/proto/gen/proto/v2/gateway_pb"; +import type { + ChatActivityEvent, + ChatCommandUpdate, + ChatRunActivity, + ChatRunSnapshot, + ChatSubscribeResult, + StatusEvent, + TerminalServerFrame, + WebClientFrame, + WebServerFrame, +} from "@/lib/proto/gen/proto/v2/gateway_ws_pb"; +import { + AgentListRequestSchema, + ChatActivitiesRequestSchema, + ChatPrepareRequestSchema, + ChatSubscribeRequestSchema, + ChatUnsubscribeRequestSchema, + ClientHelloSchema, + ClientRole, + PongFrameSchema, + StatusGetRequestSchema, + TerminalClientFrameSchema, + TerminalServerFrameSchema, + WebClientFrameSchema, + WebServerFrameSchema, + WorkspaceSubscribeRequestSchema, + WorkspaceUnsubscribeRequestSchema, +} from "@/lib/proto/gen/proto/v2/gateway_ws_pb"; + +// v2 WebSocket 子协议名(服务端必须回显)。 +export const GATEWAY_V2_SUBPROTOCOL = "liveagent.v2.pb"; +// ClientHello.protocol_version 的当前取值。 +export const GATEWAY_V2_PROTOCOL_VERSION = 2; + +const textDecoder = new TextDecoder(); + +type J = Record; + +// --------------------------------------------------------------------------- +// 基础读写小工具(入站 JSON 载荷是 unknown,出站 proto 需要窄化类型) +// --------------------------------------------------------------------------- + +function rec(value: unknown): J { + return value && typeof value === "object" ? (value as J) : {}; +} + +function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function trimStr(value: unknown): string { + return str(value).trim(); +} + +// 32 位整数出站:非法值一律回落为 0。 +function n32(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : 0; +} + +function bool(value: unknown): boolean { + return value === true; +} + +function optBool(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function optPositiveU32(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 0xffff_ffff ? parsed : undefined; +} + +// 64 位整数出站边界:number → bigint。 +function toI64(value: unknown): bigint { + const parsed = Number(value); + return Number.isFinite(parsed) ? BigInt(Math.trunc(parsed)) : 0n; +} + +// 64 位整数入站边界:bigint → number(详见文件头注释)。 +function num(value: number | bigint | undefined): number { + return Number(value ?? 0); +} + +function parseJson(text: string): unknown { + return JSON.parse(text); +} + +// WireBytes:可直接投递给 WebSocket.send 的二进制帧。toBinary 的泛型参数是 ArrayBufferLike, +// 运行时总是普通 ArrayBuffer,此处统一收窄避免各调用点重复断言。 +export type WireBytes = Uint8Array; + +function wireBytes(bytes: Uint8Array): WireBytes { + return bytes as WireBytes; +} + +// 网关本地或解码期错误,由 gatewaySocket 统一转为请求失败。 +class GatewayFrameError extends Error {} + +function frameError(message: string): never { + throw new GatewayFrameError(message); +} + +// --------------------------------------------------------------------------- +// 出站:hello / pong / 请求编码 +// --------------------------------------------------------------------------- + +export function encodeHelloFrame(requestId: string, token: string): WireBytes { + const frame = create(WebClientFrameSchema, { + requestId, + payload: { + case: "hello", + value: create(ClientHelloSchema, { + protocolVersion: GATEWAY_V2_PROTOCOL_VERSION, + role: ClientRole.BROWSER, + token, + clientName: "webui", + clientVersion: "", + }), + }, + }); + return wireBytes(toBinary(WebClientFrameSchema, frame)); +} + +export function encodePongFrame(timestamp: number): WireBytes { + const frame = create(WebClientFrameSchema, { + payload: { case: "pong", value: create(PongFrameSchema, { timestamp: toI64(timestamp) }) }, + }); + return wireBytes(toBinary(WebClientFrameSchema, frame)); +} + +// 把请求类型字符串与 UI 载荷编码为 WebClientFrame;未知类型抛错。 +// 目标型请求必须携带明确 agentId;目录与全局会话请求不需要目标。 +export function encodeRequestFrame( + requestId: string, + type: string, + payload: unknown, + agentId = "", +): WireBytes { + const frame = buildRequestFrame(requestId, type, payload); + const normalizedAgentId = agentId.trim(); + if (!normalizedAgentId && !["agent.list", "chat.activities"].includes(type)) { + throw new Error("agent_id is required"); + } + frame.agentId = normalizedAgentId; + return wireBytes(toBinary(WebClientFrameSchema, frame)); +} + +function buildRequestFrame(requestId: string, type: string, payload: unknown): WebClientFrame { + const body = rec(payload); + switch (type) { + case "agent.list": + return create(WebClientFrameSchema, { + requestId, + payload: { case: "agentList", value: create(AgentListRequestSchema, {}) }, + }); + case "status.get": + return webFrame(requestId, "statusGet", create(StatusGetRequestSchema, {})); + case "chat.prepare": + return webFrame( + requestId, + "chatPrepare", + create(ChatPrepareRequestSchema, { reason: str(body.reason) }), + ); + case "chat.command": + return webFrame(requestId, "chatCommand", buildChatCommand(body)); + case "chat.cancel": + return webFrame( + requestId, + "chatCommand", + create(ChatCommandRequestSchema, { + type: "chat.cancel", + cancel: create(CancelChatRequestSchema, { + conversationId: trimStr(body.conversation_id), + runId: trimStr(body.run_id), + }), + }), + ); + case "chat.subscribe": + return webFrame( + requestId, + "chatSubscribe", + create(ChatSubscribeRequestSchema, { + conversationId: trimStr(body.conversation_id), + afterSeq: toI64(body.after_seq), + streamEpoch: str(body.stream_epoch), + }), + ); + case "chat.unsubscribe": + return webFrame( + requestId, + "chatUnsubscribe", + create(ChatUnsubscribeRequestSchema, { conversationId: trimStr(body.conversation_id) }), + ); + case "chat.activities": + return webFrame(requestId, "chatActivities", create(ChatActivitiesRequestSchema, {})); + case "workspace.subscribe": + return webFrame( + requestId, + "workspaceSubscribe", + create(WorkspaceSubscribeRequestSchema, { workdir: trimStr(body.workdir) }), + ); + case "workspace.unsubscribe": + return webFrame( + requestId, + "workspaceUnsubscribe", + create(WorkspaceUnsubscribeRequestSchema, { workdir: trimStr(body.workdir) }), + ); + default: + return create(WebClientFrameSchema, { + requestId, + payload: { case: "agentRequest", value: buildAgentRequest(type, body) }, + }); + } +} + +type WebFrameCase = + | "statusGet" + | "chatCommand" + | "chatPrepare" + | "chatSubscribe" + | "chatUnsubscribe" + | "chatActivities" + | "workspaceSubscribe" + | "workspaceUnsubscribe"; + +function webFrame(requestId: string, frameCase: WebFrameCase, value: unknown): WebClientFrame { + return create(WebClientFrameSchema, { + requestId, + // 受控断言换取 switch 简洁:各分支 value 均为对应 schema 实例,oneof 判别类型无法自动收窄。 + payload: { case: frameCase, value } as WebClientFrame["payload"], + }); +} + +function buildChatCommand(body: J) { + const inner = rec(body.payload); + const selectedModel = rec(inner.selected_model); + const runtimeControls = rec(inner.runtime_controls); + const uploadedFiles = Array.isArray(inner.uploaded_files) ? inner.uploaded_files : []; + return create(ChatCommandRequestSchema, { + type: str(body.type), + request: create(ChatRequestSchema, { + conversationId: str(inner.conversation_id), + message: str(inner.message), + selectedModel: inner.selected_model + ? create(ChatSelectedModelSchema, { + customProviderId: str(selectedModel.custom_provider_id), + model: str(selectedModel.model), + providerType: str(selectedModel.provider_type), + }) + : undefined, + executionMode: str(inner.execution_mode), + workdir: str(inner.workdir), + uploadedFiles: uploadedFiles.map((file) => { + const raw = rec(file); + return create(ChatUploadedFileSchema, { + relativePath: str(raw.relative_path), + absolutePath: str(raw.absolute_path), + fileName: str(raw.file_name), + kind: str(raw.kind), + sizeBytes: toI64(raw.size_bytes), + }); + }), + clientRequestId: str(inner.client_request_id), + runtimeControls: inner.runtime_controls + ? create(ChatRuntimeControlsSchema, { + thinkingEnabled: bool(runtimeControls.thinking_enabled), + nativeWebSearchEnabled: bool(runtimeControls.native_web_search_enabled), + reasoning: str(runtimeControls.reasoning), + }) + : undefined, + queuePolicy: str(inner.queue_policy), + }), + baseMessageRef: inner.base_message_ref + ? buildMessageRef(rec(inner.base_message_ref)) + : undefined, + }); +} + +function buildMessageRef(ref: J) { + return create(ChatMessageRefSchema, { + segmentIndex: n32(ref.segment_index), + messageIndex: n32(ref.message_index), + segmentId: str(ref.segment_id), + messageId: str(ref.message_id), + role: str(ref.role), + contentHash: str(ref.content_hash), + }); +} + +// 构造直通 GatewayEnvelope,并把 UI 字段映射到对应 proto 载荷臂。 +function buildAgentRequest(type: string, body: J): GatewayEnvelope { + return create(GatewayEnvelopeSchema, { payload: agentRequestPayload(type, body) }); +} + +function agentRequestPayload(type: string, body: J): GatewayEnvelope["payload"] { + if (type.startsWith("git.")) { + return { + case: "gitRequest", + value: create(GitRequestSchema, { + action: type.slice("git.".length), + workdir: trimStr(body.workdir), + argsJson: body.args === undefined ? "{}" : JSON.stringify(body.args), + }), + }; + } + if (type.startsWith("terminal.")) { + return { + case: "terminalRequest", + value: create(TerminalRequestSchema, { + action: type.slice("terminal.".length), + sessionId: trimStr(body.session_id), + projectPathKey: trimStr(body.project_path_key), + cwd: trimStr(body.cwd), + shell: trimStr(body.shell), + title: trimStr(body.title), + data: str(body.data), + cols: n32(body.cols), + rows: n32(body.rows), + maxBytes: n32(body.max_bytes), + sshHostId: trimStr(body.ssh_host_id), + promptId: trimStr(body.prompt_id), + promptAnswer: str(body.prompt_answer), + trustHostKey: bool(body.trust_host_key), + sftpEnabled: bool(body.sftp_enabled), + tabId: trimStr(body.tab_id), + tabKind: trimStr(body.tab_kind), + remoteHost: trimStr(body.remote_host), + remotePort: n32(body.remote_port), + localPort: n32(body.local_port), + forwardId: trimStr(body.forward_id), + }), + }; + } + if (type.startsWith("sftp.")) { + // UI 载荷中的 side 与 direction 互为回落;proto 只保留 direction。 + const direction = trimStr(body.direction) || trimStr(body.side); + return { + case: "sftpRequest", + value: create(SftpRequestSchema, { + action: type.slice("sftp.".length), + sessionId: trimStr(body.session_id), + projectPathKey: trimStr(body.project_path_key), + workdir: trimStr(body.workdir), + localPath: str(body.local_path), + remotePath: str(body.remote_path), + fromPath: str(body.from_path), + toPath: str(body.to_path), + direction, + targetPath: str(body.target_path), + recursive: bool(body.recursive), + overwrite: bool(body.overwrite), + }), + }; + } + if (type.startsWith("tunnel.")) { + return { + case: "tunnelMutation", + value: create(TunnelMutationSchema, { + action: type.slice("tunnel.".length), + tunnelId: str(body.tunnel_id), + targetUrl: str(body.target_url), + name: str(body.name), + ttlSeconds: typeof body.ttl_seconds === "number" ? n32(body.ttl_seconds) : undefined, + projectPathKey: str(body.project_path_key), + }), + }; + } + if (type.startsWith("process.")) { + return { + case: "managedProcessRequest", + value: create(ManagedProcessRequestSchema, { + action: type.slice("process.".length), + processId: trimStr(body.process_id), + maxBytes: n32(body.max_bytes), + }), + }; + } + if (type.startsWith("chat_queue.")) { + return { + case: "chatQueue", + value: create(ChatQueueRequestSchema, { + action: type.slice("chat_queue.".length), + conversationId: trimStr(body.conversation_id), + itemId: trimStr(body.item_id), + direction: trimStr(body.direction), + revision: toI64(body.revision), + draftJson: trimStr(body.draft_json), + uploadedFilesJson: trimStr(body.uploaded_files_json), + requestJson: trimStr(body.request_json), + }), + }; + } + + switch (type) { + case "history.list": + return { + case: "historyList", + value: create(HistoryListRequestSchema, { + page: n32(body.page), + pageSize: n32(body.page_size), + cwd: trimStr(body.cwd), + cwdEmpty: bool(body.cwd_empty), + }), + }; + case "history.workdirs": + return { case: "historyWorkdirs", value: create(HistoryWorkdirsRequestSchema, {}) }; + case "history.shared_list": + // shared_list 通过 memory_manage 直通命令实现,并保持现有结果形状。 + return { + case: "memoryManage", + value: create(MemoryManageRequestSchema, { + command: "history_shared_list", + argsJson: JSON.stringify({ page: n32(body.page), page_size: n32(body.page_size) }), + }), + }; + case "history.get": + return { + case: "historyGet", + value: create(HistoryGetRequestSchema, { + conversationId: trimStr(body.conversation_id), + maxMessages: n32(body.max_messages), + }), + }; + case "history.prefix": + return { + case: "historyPrefix", + value: create(HistoryPrefixRequestSchema, { + conversationId: trimStr(body.conversation_id), + maxMessages: n32(body.max_messages), + baseMessageRef: body.base_message_ref + ? buildMessageRef(rec(body.base_message_ref)) + : undefined, + }), + }; + case "history.rename": + return { + case: "historyRename", + value: create(HistoryRenameRequestSchema, { + conversationId: trimStr(body.conversation_id), + title: trimStr(body.title), + }), + }; + case "history.branch": + return { + case: "historyBranch", + value: create(HistoryBranchRequestSchema, { + conversationId: trimStr(body.conversation_id), + baseMessageRef: body.base_message_ref + ? buildMessageRef(rec(body.base_message_ref)) + : undefined, + }), + }; + case "history.pin": + return { + case: "historyPin", + value: create(HistoryPinRequestSchema, { + conversationId: trimStr(body.conversation_id), + isPinned: bool(body.is_pinned), + }), + }; + case "history.set_cwd": + return { + case: "historySetCwd", + value: create(HistorySetCwdRequestSchema, { + conversationId: trimStr(body.conversation_id), + cwd: trimStr(body.cwd), + }), + }; + case "history.share.get": + return { + case: "historyShareGet", + value: create(HistoryShareGetRequestSchema, { + conversationId: trimStr(body.conversation_id), + }), + }; + case "history.share.set": + return { + case: "historyShareSet", + value: create(HistoryShareSetRequestSchema, { + conversationId: trimStr(body.conversation_id), + enabled: bool(body.enabled), + redactToolContent: optBool(body.redact_tool_content), + }), + }; + case "history.delete": + return { + case: "historyDelete", + value: create(HistoryDeleteRequestSchema, { + conversationId: trimStr(body.conversation_id), + }), + }; + case "providers.list": + return { case: "providerList", value: create(ProviderListRequestSchema, {}) }; + case "provider.models": + return { + case: "providerModels", + value: create(ProviderModelsRequestSchema, { + providerType: trimStr(body.type), + baseUrl: trimStr(body.base_url), + apiKey: trimStr(body.api_key), + useSystemProxy: bool(body.use_system_proxy), + modelsUrl: trimStr(body.models_url), + }), + }; + case "provider.usage.query": + return { + case: "providerUsage", + value: create(ProviderUsageRequestSchema, { + providerId: trimStr(body.provider_id), + refresh: bool(body.refresh), + configJson: trimStr(body.config_json), + }), + }; + case "settings.get": + return { case: "settingsGet", value: create(SettingsGetRequestSchema, {}) }; + case "settings.update": + return { + case: "settingsUpdate", + value: create(SettingsUpdateRequestSchema, { settingsJson: JSON.stringify(rec(body)) }), + }; + case "settings.ssh_known_host.reset": + return { + case: "settingsResetSshKnownHost", + value: create(SettingsResetSshKnownHostRequestSchema, { + host: trimStr(body.host), + port: n32(body.port), + }), + }; + case "skills.list": + return { case: "skillFilesList", value: create(SkillFilesListRequestSchema, {}) }; + case "skills.manage": + return { + case: "skillManage", + value: create(SkillManageRequestSchema, { payloadJson: JSON.stringify(rec(body)) }), + }; + case "skills.read-metadata": + return { + case: "skillMetadataRead", + value: create(SkillMetadataReadRequestSchema, { path: trimStr(body.path) }), + }; + case "skills.read-text": + return { + case: "skillTextRead", + value: create(SkillTextReadRequestSchema, { + path: trimStr(body.path), + offset: n32(body.offset), + length: n32(body.length), + }), + }; + case "mentions.list": + return { + case: "fileMentionList", + value: create(FileMentionListRequestSchema, { + workdir: trimStr(body.workdir), + maxResults: n32(body.max_results), + query: trimStr(body.query), + showHidden: optBool(body.show_hidden), + }), + }; + case "files.preview": + return { + case: "uploadedImagePreview", + value: create(UploadedImagePreviewRequestSchema, { + workdir: trimStr(body.workdir), + absolutePath: trimStr(body.absolute_path), + }), + }; + case "memory.manage": + return { + case: "memoryManage", + value: create(MemoryManageRequestSchema, { + command: trimStr(body.command), + argsJson: body.args === undefined ? "{}" : JSON.stringify(body.args), + }), + }; + case "cron.manage": + return { + case: "cronManage", + value: create(CronManageRequestSchema, { + action: str(body.action), + taskId: str(body.task_id), + taskJson: str(body.task_json), + }), + }; + case "fs.roots": + return { case: "fsRoots", value: create(FsRootsRequestSchema, {}) }; + case "fs.list_dirs": + return { + case: "fsListDirs", + value: create(FsListDirsRequestSchema, { + path: trimStr(body.path), + maxResults: n32(body.max_results), + }), + }; + case "fs.create_project_folder": + return { + case: "fsCreateProjectFolder", + value: create(FsCreateProjectFolderRequestSchema, { + parent: trimStr(body.parent), + name: trimStr(body.name), + }), + }; + case "fs.list": + return { + case: "fsList", + value: create(FsListRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + depth: n32(body.depth), + offset: n32(body.offset), + maxResults: n32(body.max_results), + showHidden: optBool(body.show_hidden), + }), + }; + case "fs.write_text": + return { + case: "fsWriteText", + value: create(FsWriteTextRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + content: str(body.content), + mode: trimStr(body.mode) || "rewrite", + expectedMtimeMs: toI64(body.expected_mtime_ms), + expectedContentHash: trimStr(body.expected_content_hash), + hasExpectedMtimeMs: body.expected_mtime_ms !== undefined, + hasExpectedContentHash: body.expected_content_hash !== undefined, + }), + }; + case "fs.create_dir": + return { + case: "fsCreateDir", + value: create(FsCreateDirRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + case "fs.rename": + return { + case: "fsRename", + value: create(FsRenameRequestSchema, { + workdir: trimStr(body.workdir), + fromPath: trimStr(body.from_path), + toPath: trimStr(body.to_path), + }), + }; + case "fs.delete": + return { + case: "fsDelete", + value: create(FsDeleteRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + case "fs.read_editable_text": + return { + case: "fsReadEditableText", + value: create(FsReadEditableTextRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + case "fs.read_workspace_image": + return { + case: "fsReadWorkspaceImage", + value: create(FsReadWorkspaceImageRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + case "chat.file_open": + return { + case: "chatFileOpen", + value: create(ChatFileOpenRequestSchema, { + conversationId: trimStr(body.conversation_id), + workdir: trimStr(body.workdir), + path: trimStr(body.path), + source: trimStr(body.source), + line: optPositiveU32(body.line), + endLine: optPositiveU32(body.end_line), + column: optPositiveU32(body.column), + openInFileManager: bool(body.open_in_file_manager), + }), + }; + default: + throw new Error(`unsupported gateway request type: ${type}`); + } +} + +// --------------------------------------------------------------------------- +// 入站:服务端帧解码 +// --------------------------------------------------------------------------- + +export type DecodedServerFrame = + | { + kind: "hello"; + requestId: string; + ok: boolean; + message: string; + heartbeatPeriodSeconds: number; + maxMessageBytes: number; + serverTime: number; + } + | { kind: "ping"; timestamp: number } + | { kind: "response"; requestId: string; agentId: string; payload: unknown } + | { kind: "error"; requestId: string; agentId: string; message: string } + | { kind: "event"; type: string; agentId: string; payload: unknown }; + +export function decodeServerFrameBinary(data: ArrayBuffer | Uint8Array): WebServerFrame { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + return fromBinary(WebServerFrameSchema, bytes); +} + +// 把一帧 WebServerFrame 归一化为分发单元。agentOnline 用于给 process.state +// 补齐客户端从 status 事件维护的在线位。 +export function decodeServerFrame( + frame: WebServerFrame, + options: { agentOnline: boolean }, +): DecodedServerFrame | null { + const requestId = frame.requestId ?? ""; + const agentId = frame.agentId ?? ""; + const payload = frame.payload; + switch (payload.case) { + case "hello": + return { + kind: "hello", + requestId, + ok: payload.value.ok, + message: payload.value.message, + heartbeatPeriodSeconds: payload.value.heartbeatPeriodSeconds, + maxMessageBytes: num(payload.value.maxMessageBytes), + serverTime: num(payload.value.serverTime), + }; + case "ping": + return { kind: "ping", timestamp: num(payload.value.timestamp) }; + case "localError": + return { + kind: "error", + requestId, + agentId, + message: payload.value.message || "Request failed", + }; + case "agentResponse": + try { + return { + kind: "response", + requestId, + agentId, + payload: decodeAgentResponse(payload.value, options), + }; + } catch (error) { + return { + kind: "error", + requestId, + agentId, + message: error instanceof Error ? error.message : "Request failed", + }; + } + case "status": + // status 臂身兼二职:带 request_id 是 status.get/chat.prepare 响应,空则为 status.event 广播。 + return requestId + ? { kind: "response", requestId, agentId, payload: statusPayload(payload.value) } + : { kind: "event", type: "status.event", agentId, payload: statusPayload(payload.value) }; + case "chatSubscribed": + return { + kind: "response", + requestId, + agentId, + payload: chatSubscribedPayload(payload.value), + }; + case "chatAccepted": + return { + kind: "response", + requestId, + agentId, + payload: { + run_id: payload.value.runId, + conversation_id: payload.value.conversationId, + accepted_seq: num(payload.value.acceptedSeq), + deduped: payload.value.deduped, + }, + }; + case "chatActivities": + return { + kind: "response", + requestId, + agentId, + payload: { + running_conversations: payload.value.runningConversations.map(runningConversationPayload), + }, + }; + case "chatCancelled": + return { + kind: "response", + requestId, + agentId, + payload: { + ok: payload.value.ok, + run_id: payload.value.runId, + conversation_id: payload.value.conversationId, + }, + }; + case "ack": + return { kind: "response", requestId, agentId, payload: { ok: payload.value.ok } }; + case "chatEvent": { + const parsed = parseJsonBytes(payload.value.payloadJson); + return parsed === undefined + ? null + : { kind: "event", type: "chat.event", agentId, payload: parsed }; + } + case "chatCommandUpdate": + return { + kind: "event", + type: "chat.command_update", + agentId, + payload: chatCommandUpdatePayload(payload.value), + }; + case "chatSubscriptionReset": + return { + kind: "event", + type: "chat.subscription_reset", + agentId, + payload: { conversation_id: payload.value.conversationId }, + }; + case "chatActivity": + return { + kind: "event", + type: "chat.activity", + agentId, + payload: chatActivityPayload(payload.value), + }; + case "historyEvent": + return { + kind: "event", + type: "history.event", + agentId, + payload: historyEventPayload(payload.value), + }; + case "settingsEvent": { + // settings_json 在客户端解析为现有设置事件对象。 + const parsed = settingsEventPayload(payload.value); + return parsed === null + ? null + : { kind: "event", type: "settings.event", agentId, payload: parsed }; + } + case "terminalEvent": + return { + kind: "event", + type: "terminal.event", + agentId, + payload: terminalEventPayload(payload.value), + }; + case "sftpEvent": + return { + kind: "event", + type: "sftp.event", + agentId, + payload: sftpEventPayload(payload.value), + }; + case "chatQueueEvent": + return { + kind: "event", + type: "chat_queue.event", + agentId, + payload: chatQueueEventPayload(payload.value), + }; + case "tunnelState": + return { + kind: "event", + type: "tunnel.state", + agentId, + payload: tunnelStatePayload(payload.value), + }; + case "processState": + return { + kind: "event", + type: "process.state", + agentId, + payload: processStatePayload(payload.value, options.agentOnline), + }; + case "agentList": + return { + kind: "response", + requestId, + agentId, + payload: { agents: payload.value.agents.map(statusPayload) }, + }; + case "workspaceActivity": + return { + kind: "event", + type: "workspace.activity", + agentId, + payload: workspaceActivityPayload(payload.value), + }; + default: + return null; + } +} + +function parseJsonBytes(bytes: Uint8Array): unknown { + try { + return parseJson(textDecoder.decode(bytes)); + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// 直通响应(AgentEnvelope)→ gatewaySocket 响应对象 +// --------------------------------------------------------------------------- + +function decodeAgentResponse(envelope: AgentEnvelope, options: { agentOnline: boolean }): unknown { + const payload = envelope.payload; + switch (payload.case) { + case "error": + frameError(payload.value.message || "Request failed"); + break; + case "historyListResp": + // running_conversations 由 gatewaySocket 侧经 chat.activities 帧合并。 + return { + conversations: payload.value.conversations.map(conversationSummaryPayload), + total_count: payload.value.totalCount, + }; + case "historyGetResp": + case "historyPrefixResp": + return { + conversation_id: payload.value.conversationId, + messages_json: payload.value.messagesJson, + total_message_count: payload.value.totalMessageCount, + returned_message_count: payload.value.returnedMessageCount, + has_more: payload.value.hasMore, + conversation: payload.value.conversation + ? conversationSummaryPayload(payload.value.conversation) + : null, + }; + case "historyRenameResp": + case "historyBranchResp": + case "historyPinResp": + case "historySetCwdResp": + if (!payload.value.conversation) { + frameError("unexpected agent response"); + } + return conversationSummaryPayload(payload.value.conversation); + case "historyShareGetResp": + case "historyShareSetResp": + if (!payload.value.share) { + frameError("unexpected agent response"); + } + return historyShareStatusPayload(payload.value.share); + case "historyDeleteResp": + return { ok: true }; + case "historyWorkdirsResp": + return { + workdirs: payload.value.workdirs.map((workdir) => ({ + path: workdir.path, + conversation_count: workdir.conversationCount, + updated_at: num(workdir.updatedAt), + })), + }; + case "providerListResp": { + const raw = payload.value.providersJson.trim(); + if (!raw) return []; + try { + return parseJson(raw); + } catch { + frameError("provider list response is not valid JSON"); + } + break; + } + case "providerModelsResp": + try { + return parseJson(payload.value.modelsJson); + } catch { + frameError("provider model response is not valid JSON"); + } + break; + case "providerUsageResp": + try { + return parseJson(payload.value.resultJson); + } catch { + frameError("provider usage response is not valid JSON"); + } + break; + case "settingsGetResp": { + const raw = payload.value.settingsJson.trim(); + if (!raw) return {}; + try { + const parsed = parseJson(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + frameError("gateway settings payload is not valid JSON"); + } + break; + } + case "settingsUpdateResp": + return { accepted: payload.value.accepted, message: payload.value.message.trim() }; + case "settingsResetSshKnownHostResp": + return { deleted: payload.value.deleted }; + case "skillFilesListResp": + return { + rootDir: payload.value.rootDir, + paths: payload.value.paths, + truncated: payload.value.truncated, + }; + case "skillMetadataReadResp": { + const name = payload.value.name.trim(); + const description = payload.value.description.trim(); + return { name: name || null, description: description || null }; + } + case "skillTextReadResp": + return { content: payload.value.content, truncated: payload.value.truncated }; + case "skillManageResp": { + const raw = payload.value.resultJson.trim(); + if (!raw) return {}; + try { + return parseJson(raw); + } catch { + frameError("skill manage response is not valid JSON"); + } + break; + } + case "fileMentionListResp": + return { + entries: payload.value.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + hidden: entry.hidden, + })), + truncated: payload.value.truncated, + }; + case "uploadedImagePreviewResp": + return { mimeType: payload.value.mimeType, data: payload.value.data }; + case "memoryManageResp": + return unmarshalJsonPayload(payload.value.resultJson); + case "cronManageResp": + return { action: payload.value.action, result_json: payload.value.resultJson }; + case "fsRootsResp": + return { + roots: payload.value.roots.map((root) => ({ + id: root.id, + path: root.path, + kind: root.kind, + label: root.label, + })), + }; + case "fsListDirsResp": + return { + path: payload.value.path.trim(), + entries: payload.value.entries.map((entry) => ({ path: entry.path, name: entry.name })), + truncated: payload.value.truncated, + }; + case "fsCreateProjectFolderResp": + return { path: payload.value.path.trim() }; + case "fsListResp": + return { + path: payload.value.hasPath ? payload.value.path : null, + depth: payload.value.depth, + offset: payload.value.offset, + maxResults: payload.value.maxResults, + total: payload.value.total, + hasMore: payload.value.hasMore, + entries: payload.value.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + hidden: entry.hidden, + })), + }; + case "fsReadEditableTextResp": + return { + path: payload.value.path, + content: payload.value.content, + mtimeMs: num(payload.value.mtimeMs), + contentHash: payload.value.contentHash, + sizeBytes: num(payload.value.sizeBytes), + totalLines: num(payload.value.totalLines), + }; + case "fsReadWorkspaceImageResp": + return { + path: payload.value.path, + mimeType: payload.value.mimeType, + data: payload.value.data, + sizeBytes: num(payload.value.sizeBytes), + mtimeMs: num(payload.value.mtimeMs), + contentHash: payload.value.contentHash, + }; + case "chatFileOpenResp": + return { + action: payload.value.action, + kind: payload.value.kind, + workdir: payload.value.workdir || undefined, + path: payload.value.path || undefined, + line: payload.value.line, + endLine: payload.value.endLine, + column: payload.value.column, + outsideWorkspace: payload.value.outsideWorkspace, + }; + case "fsWriteTextResp": + return { + path: payload.value.path, + mode: payload.value.mode, + existedBefore: payload.value.existedBefore, + bytesWritten: num(payload.value.bytesWritten), + mtimeMs: num(payload.value.mtimeMs), + contentHash: payload.value.contentHash, + totalLines: num(payload.value.totalLines), + }; + case "fsCreateDirResp": + return { path: payload.value.path, kind: payload.value.kind }; + case "fsRenameResp": + return { + fromPath: payload.value.fromPath, + path: payload.value.path, + kind: payload.value.kind, + }; + case "fsDeleteResp": + return { path: payload.value.path, kind: payload.value.kind }; + case "gitResponse": + return unmarshalJsonPayload(payload.value.resultJson); + case "sftpResponse": + return sftpResponsePayload(payload.value); + case "chatQueueResp": + return { + accepted: payload.value.accepted, + message: payload.value.message, + snapshot_json: payload.value.snapshotJson, + item_json: payload.value.itemJson, + error_code: payload.value.errorCode, + revision: num(payload.value.revision), + }; + case "terminalResponse": + return terminalResponsePayload(payload.value); + case "tunnelMutationResult": + if (payload.value.errorMessage) { + frameError(payload.value.errorMessage); + } + return { tunnel_id: payload.value.tunnelId }; + case "managedProcessResponse": + return managedProcessResponsePayload(payload.value, options.agentOnline); + default: + frameError("unexpected agent response"); + } +} + +function unmarshalJsonPayload(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + const parsed = parseJson(trimmed); + return parsed ?? {}; + } catch { + frameError("response is not valid JSON"); + } +} + +// --------------------------------------------------------------------------- +// gatewaySocket 载荷塑形 +// --------------------------------------------------------------------------- + +// 对应 websocketConversationSummaryPayload:protojson UseProtoNames+EmitUnpopulated(全字段恒出现), +// 64 位数字转 number。 +function conversationSummaryPayload(conversation: ConversationSummary): J { + return { + id: conversation.id, + title: conversation.title, + created_at: num(conversation.createdAt), + updated_at: num(conversation.updatedAt), + message_count: conversation.messageCount, + provider_id: conversation.providerId, + model: conversation.model, + session_id: conversation.sessionId, + cwd: conversation.cwd, + is_pinned: conversation.isPinned, + pinned_at: num(conversation.pinnedAt), + is_shared: conversation.isShared, + selected_model_json: conversation.selectedModelJson, + }; +} + +function historyShareStatusPayload(share: HistoryShareStatus): J { + return { + conversation_id: share.conversationId, + enabled: share.enabled, + token: share.token, + created_at: num(share.createdAt), + updated_at: num(share.updatedAt), + redact_tool_content: share.redactToolContent, + }; +} + +function historyEventPayload(event: HistorySyncEvent): J { + const payload: J = { + kind: event.kind.trim(), + conversation_id: event.conversationId.trim(), + }; + if (event.conversation) { + payload.conversation = conversationSummaryPayload(event.conversation); + } + return payload; +} + +function settingsEventPayload(event: SettingsSyncEvent): J | null { + const raw = event.settingsJson.trim(); + if (!raw) return {}; + try { + const parsed = parseJson(raw); + return parsed && typeof parsed === "object" ? (parsed as J) : {}; + } catch { + return null; + } +} + +function chatQueueEventPayload(event: ChatQueueEvent): J { + return { + conversation_id: event.conversationId.trim(), + snapshot_json: event.snapshotJson.trim(), + revision: num(event.revision), + }; +} + +function workspaceActivityPayload(event: WorkspaceActivityEvent): J { + return { + workdir: event.workdir, + revision: num(event.revision), + fs: event.fs, + git: event.git, + changedPaths: event.changedPaths, + truncated: event.truncated, + }; +} + +// 对应 session.Status 的 json tag(含 omitempty 语义)。 +function statusPayload(status: StatusEvent): J { + const payload: J = { + online: status.online, + agent_ready: status.agentReady, + chat_runtime_ready: status.chatRuntimeReady, + agent_id: status.agentId, + agent_version: status.agentVersion, + connected_since: num(status.connectedSince), + last_heartbeat: num(status.lastHeartbeat), + }; + if (status.sessionId) payload.session_id = status.sessionId; + if (status.runtimeState) payload.runtime_state = status.runtimeState; + if (num(status.runtimeLastHeartbeat) !== 0) { + payload.runtime_last_heartbeat = num(status.runtimeLastHeartbeat); + } + if (status.runtimeWorkerId) payload.runtime_worker_id = status.runtimeWorkerId; + if (status.runtimeVisible) payload.runtime_visible = status.runtimeVisible; + if (status.runtimeActiveRunCount) { + payload.runtime_active_run_count = status.runtimeActiveRunCount; + } + if (status.name) payload.name = status.name; + return payload; +} + +// 对应 websocketRunActivityPayload(updated_at 为 Unix 毫秒)。 +function runActivityPayload(activity: ChatRunActivity | undefined): J | null { + if (!activity || !activity.runId) return null; + const payload: J = { + run_id: activity.runId, + state: activity.state, + started_seq: num(activity.startedSeq), + updated_at: num(activity.updatedAtMs), + }; + if (activity.toolStatus) { + payload.tool_status = activity.toolStatus; + payload.tool_status_is_compaction = activity.toolStatusIsCompaction; + } + if (activity.clientRequestId) { + payload.client_request_id = activity.clientRequestId; + } + return payload; +} + +function runSnapshotPayload(snapshot: ChatRunSnapshot | undefined): J | null { + if (!snapshot || !snapshot.runId) return null; + return { + run_id: snapshot.runId, + revision: num(snapshot.revision), + entries_json: snapshot.entriesJson, + tool_status: snapshot.toolStatus, + tool_status_is_compaction: snapshot.toolStatusIsCompaction, + as_of_seq: num(snapshot.asOfSeq), + }; +} + +function chatSubscribedPayload(result: ChatSubscribeResult): J { + const events: unknown[] = []; + for (const raw of result.eventsJson) { + const parsed = parseJsonBytes(raw); + if (parsed !== undefined) { + events.push(parsed); + } + } + return { + conversation_id: result.conversationId, + stream_epoch: result.streamEpoch, + latest_seq: num(result.latestSeq), + reset: result.reset, + activity: runActivityPayload(result.activity), + snapshot: runSnapshotPayload(result.snapshot), + events, + }; +} + +// 对应 websocketRunningConversationsPayload(workdir 映射为 cwd)。 +function runningConversationPayload(activity: ChatRunActivity): J { + return { + conversation_id: activity.conversationId, + run_id: activity.runId, + state: activity.state, + cwd: activity.workdir, + updated_at: num(activity.updatedAtMs), + }; +} + +// 对应 websocketChatActivityPayload(可选键仅在非空时出现)。 +function chatActivityPayload(event: ChatActivityEvent): J { + const payload: J = { + conversation_id: event.conversationId, + running: event.running, + updated_at: num(event.updatedAtMs), + }; + if (event.runId) payload.run_id = event.runId; + if (event.clientRequestId) payload.client_request_id = event.clientRequestId; + if (event.state) payload.state = event.state; + if (event.workdir) payload.workdir = event.workdir; + return payload; +} + +function chatCommandUpdatePayload(update: ChatCommandUpdate): J { + const payload: J = { + run_id: update.runId, + client_request_id: update.clientRequestId, + phase: update.phase, + }; + if (update.conversationId) payload.conversation_id = update.conversationId; + if (update.errorCode) payload.error_code = update.errorCode; + if (update.message) payload.message = update.message; + return payload; +} + +// --------------------------------------------------------------------------- +// 终端 / SFTP / 隧道 / 进程载荷(对应 websocket_payloads.go 等) +// --------------------------------------------------------------------------- + +export function terminalSessionPayload(session: TerminalSession | undefined): J | null { + if (!session) return null; + const kind = session.kind.trim() === "ssh" ? "ssh" : "local"; + const payload: J = { + id: session.id.trim(), + project_path_key: session.projectPathKey.trim(), + cwd: session.cwd.trim(), + shell: session.shell.trim(), + title: session.title.trim(), + kind, + pid: session.pid, + cols: session.cols, + rows: session.rows, + created_at: num(session.createdAt), + updated_at: num(session.updatedAt), + finished_at: num(session.finishedAt), + exit_code: session.exitCode, + running: session.running, + }; + if (session.pid === 0) payload.pid = null; + if (num(session.finishedAt) === 0) payload.finished_at = null; + if (kind === "ssh") payload.pid = null; + if (session.ssh) { + payload.ssh = { + host_id: session.ssh.hostId.trim(), + host_name: session.ssh.hostName.trim(), + username: session.ssh.username.trim(), + host: session.ssh.host.trim(), + port: session.ssh.port, + auth_type: session.ssh.authType.trim(), + status: session.ssh.status.trim(), + reconnect_attempt: session.ssh.reconnectAttempt, + reconnect_max_attempts: session.ssh.reconnectMaxAttempts, + sftp_enabled: session.ssh.sftpEnabled, + sftpEnabled: session.ssh.sftpEnabled, + }; + } + return payload; +} + +function terminalSshTabsPayload(snapshot: TerminalSshTabsSnapshot | undefined): J | null { + if (!snapshot) return null; + return { + project_path_key: snapshot.projectPathKey.trim(), + tabs: snapshot.tabs.map((tab) => ({ + id: tab.id.trim(), + session_id: tab.sessionId.trim(), + project_path_key: tab.projectPathKey.trim(), + kind: tab.kind.trim(), + created_at: num(tab.createdAt), + updated_at: num(tab.updatedAt), + })), + revision: num(snapshot.revision), + }; +} + +function terminalSshLocalForwardPayload(forward: TerminalSshLocalForward | undefined): J | null { + if (!forward) return null; + const payload: J = { + id: forward.id.trim(), + session_id: forward.sessionId.trim(), + project_path_key: forward.projectPathKey.trim(), + local_host: forward.localHost.trim(), + local_port: forward.localPort, + address: forward.address.trim(), + remote_host: forward.remoteHost.trim(), + remote_port: forward.remotePort, + status: forward.status.trim(), + created_at: num(forward.createdAt), + updated_at: num(forward.updatedAt), + }; + if (forward.error.trim()) payload.error = forward.error.trim(); + return payload; +} + +function terminalSshLocalForwardsPayload( + snapshot: TerminalSshLocalForwardsSnapshot | undefined, +): J | null { + if (!snapshot) return null; + const forwards: J[] = []; + for (const forward of snapshot.forwards) { + const payload = terminalSshLocalForwardPayload(forward); + if (payload) forwards.push(payload); + } + return { forwards, revision: num(snapshot.revision) }; +} + +function terminalSshLocalForwardActionPayload( + action: TerminalSshLocalForwardAction | undefined, +): J | null { + const forward = terminalSshLocalForwardPayload(action?.forward); + if (!action || !forward) return null; + const payload: J = { forward, revision: num(action.revision) }; + if (action.kind.trim()) payload.kind = action.kind.trim(); + return payload; +} + +function terminalResponsePayload(resp: TerminalResponse): J { + const sessions: J[] = []; + for (const session of resp.sessions) { + const payload = terminalSessionPayload(session); + if (payload) sessions.push(payload); + } + const payload: J = { + action: resp.action.trim(), + sessions, + output: textDecoder.decode(resp.output), + output_bytes: resp.output, + truncated: resp.truncated, + shell_options: resp.shellOptions.map((option) => ({ + id: option.id.trim(), + label: option.label.trim(), + command: option.command.trim(), + })), + default_shell: resp.defaultShell, + }; + if ( + num(resp.outputStartOffset) !== 0 || + num(resp.outputEndOffset) !== 0 || + resp.output.length > 0 + ) { + payload.output_start_offset = num(resp.outputStartOffset); + payload.output_end_offset = num(resp.outputEndOffset); + } + if (resp.latencyMs > 0) payload.latency_ms = resp.latencyMs; + const session = terminalSessionPayload(resp.session); + if (session) payload.session = session; + if (resp.sshPrompt) { + payload.ssh_prompt = { + id: resp.sshPrompt.id.trim(), + kind: resp.sshPrompt.kind.trim(), + host_id: resp.sshPrompt.hostId.trim(), + host_name: resp.sshPrompt.hostName.trim(), + host: resp.sshPrompt.host.trim(), + port: resp.sshPrompt.port, + message: resp.sshPrompt.message.trim(), + fingerprint_sha256: resp.sshPrompt.fingerprintSha256.trim(), + key_type: resp.sshPrompt.keyType.trim(), + answer_echo: resp.sshPrompt.answerEcho, + }; + } + const sshTabs = terminalSshTabsPayload(resp.sshTabs); + if (sshTabs) payload.ssh_tabs = sshTabs; + const sshLocalForwards = terminalSshLocalForwardsPayload(resp.sshLocalForwards); + if (sshLocalForwards) payload.ssh_local_forwards = sshLocalForwards; + const sshLocalForward = terminalSshLocalForwardActionPayload(resp.sshLocalForward); + if (sshLocalForward) payload.ssh_local_forward = sshLocalForward; + if (resp.action.trim() === "ssh_local_forward_check_port") { + payload.ssh_local_forward_port_available = resp.sshLocalForwardPortAvailable; + } + return payload; +} + +function terminalEventPayload(event: TerminalEvent): J { + const payload: J = { + kind: event.kind.trim(), + session_id: event.sessionId.trim(), + project_path_key: event.projectPathKey.trim(), + }; + if (event.data.length > 0) { + payload.data = textDecoder.decode(event.data); + payload.data_bytes = event.data; + } + if ( + num(event.outputStartOffset) !== 0 || + num(event.outputEndOffset) !== 0 || + event.data.length > 0 + ) { + payload.output_start_offset = num(event.outputStartOffset); + payload.output_end_offset = num(event.outputEndOffset); + } + const session = terminalSessionPayload(event.session); + if (session) payload.session = session; + const sshTabs = terminalSshTabsPayload(event.sshTabs); + if (sshTabs) payload.ssh_tabs = sshTabs; + const sshLocalForward = terminalSshLocalForwardActionPayload(event.sshLocalForward); + if (sshLocalForward) payload.ssh_local_forward = sshLocalForward; + return payload; +} + +function sftpEntryPayload(entry: SftpEntry): J { + return { + path: entry.path, + name: entry.name, + kind: entry.kind.trim(), + sizeBytes: num(entry.sizeBytes), + size_bytes: num(entry.sizeBytes), + mtime: num(entry.mtime), + }; +} + +function sftpTransferPayload(transfer: SftpTransfer): J { + return { + id: transfer.id.trim(), + sessionId: transfer.sessionId.trim(), + session_id: transfer.sessionId.trim(), + direction: transfer.direction.trim(), + status: transfer.status.trim(), + sourcePath: transfer.sourcePath, + source_path: transfer.sourcePath, + targetPath: transfer.targetPath, + target_path: transfer.targetPath, + currentPath: transfer.currentPath, + current_path: transfer.currentPath, + bytesDone: num(transfer.bytesDone), + bytes_done: num(transfer.bytesDone), + bytesTotal: num(transfer.bytesTotal), + bytes_total: num(transfer.bytesTotal), + filesDone: transfer.filesDone, + files_done: transfer.filesDone, + filesTotal: transfer.filesTotal, + files_total: transfer.filesTotal, + error: transfer.error.trim(), + }; +} + +function sftpResponsePayload(resp: SftpResponse): J { + const payload: J = { + action: resp.action.trim(), + path: resp.path, + exists: resp.exists, + entries: resp.entries.map(sftpEntryPayload), + }; + if (resp.entry) payload.entry = sftpEntryPayload(resp.entry); + if (resp.transfer) payload.transfer = sftpTransferPayload(resp.transfer); + return payload; +} + +function sftpEventPayload(event: SftpEvent): J { + const payload: J = { kind: event.kind.trim() }; + if (event.transfer) payload.transfer = sftpTransferPayload(event.transfer); + return payload; +} + +function tunnelHealthPayload(health: TunnelHealth | undefined): J | null { + if (!health) return null; + return { + status: health.status, + http_status: health.httpStatus, + error: health.error, + checked_at: num(health.checkedAt), + rtt_ms: health.rttMs, + }; +} + +function tunnelStatePayload(snapshot: TunnelStateSnapshot): J { + return { + revision: num(snapshot.revision), + agent_online: snapshot.agentOnline, + relay: tunnelHealthPayload(snapshot.relay), + tunnels: snapshot.tunnels.map((tunnel) => ({ + id: tunnel.id, + slug: tunnel.slug, + name: tunnel.name, + target_url: tunnel.targetUrl, + public_path: tunnel.publicPath, + created_at: num(tunnel.createdAt), + expires_at: num(tunnel.expiresAt), + active_connections: tunnel.activeConnections, + project_path_key: tunnel.projectPathKey, + local: tunnelHealthPayload(tunnel.local), + })), + }; +} + +// 托管进程载荷;agent_online 由客户端维护的 status 在线位注入。 +function processStatePayload( + snapshot: ManagedProcessSnapshot | undefined, + agentOnline: boolean, +): J { + const processes: J[] = []; + let revision = 0; + if (snapshot) { + revision = num(snapshot.revision); + for (const record of snapshot.processes) { + const entry: J = { + id: record.id, + label: record.label, + command: record.command, + cwd: record.cwd, + shell: record.shell, + pid: record.pid, + log_path: record.logPath, + started_at: num(record.startedAt), + running: record.running, + isolated: record.isolated, + restored: record.restored, + }; + if (record.finishedAt !== undefined) entry.finished_at = num(record.finishedAt); + if (record.exitCode !== undefined) entry.exit_code = record.exitCode; + processes.push(entry); + } + } + return { revision, agent_online: agentOnline, processes }; +} + +function managedProcessResponsePayload( + resp: ManagedProcessResponse, + agentOnline: boolean, +): unknown { + const action = resp.action.trim(); + switch (action) { + case "snapshot": + // process.snapshot 返回扁平状态载荷。 + return processStatePayload(resp.snapshot, agentOnline); + case "stop": + return { + action, + stopped: resp.stopped, + state: processStatePayload(resp.snapshot, agentOnline), + }; + case "clear": + return { action, state: processStatePayload(resp.snapshot, agentOnline) }; + case "read_log": + return { + action, + log_content: resp.logContent, + log_path: resp.logPath, + log_truncated: resp.logTruncated, + }; + default: + return { action }; + } +} + +// --------------------------------------------------------------------------- +// 终端数据面(/ws/v2/terminal)帧编解码 +// --------------------------------------------------------------------------- + +// TerminalWireHeader 保持旧自定义帧头字段命名,上层 attach/snapshot/output/error 路由无需感知 proto 化。 +export type TerminalWireHeader = { + kind?: string; + streamId?: string; + sessionId?: string; + projectPathKey?: string; + seq?: number; + startOffset?: number; + endOffset?: number; + cols?: number; + rows?: number; + maxBytes?: number; + truncated?: boolean; + error?: string; + session?: unknown; +}; + +// agentId 通过 hello.agent_id 显式绑定终端数据面的目标 Agent。 +export function encodeTerminalHelloFrame(token: string, agentId: string): WireBytes { + const normalizedAgentId = agentId.trim(); + if (!normalizedAgentId) { + throw new Error("agent_id is required"); + } + const frame = create(TerminalClientFrameSchema, { + payload: { + case: "hello", + value: create(ClientHelloSchema, { + protocolVersion: GATEWAY_V2_PROTOCOL_VERSION, + role: ClientRole.BROWSER, + token, + agentId: normalizedAgentId, + clientName: "webui", + clientVersion: "", + }), + }, + }); + return wireBytes(toBinary(TerminalClientFrameSchema, frame)); +} + +export function encodeTerminalStreamFrame( + header: TerminalWireHeader, + data: Uint8Array = new Uint8Array(), +): WireBytes { + const frame = create(TerminalClientFrameSchema, { + payload: { + case: "frame", + value: create(TerminalStreamFrameSchema, { + kind: header.kind?.trim() ?? "", + streamId: header.streamId ?? "", + sessionId: header.sessionId ?? "", + projectPathKey: header.projectPathKey ?? "", + seq: toI64(header.seq), + startOffset: toI64(header.startOffset), + endOffset: toI64(header.endOffset), + cols: n32(header.cols), + rows: n32(header.rows), + maxBytes: n32(header.maxBytes), + truncated: header.truncated === true, + error: header.error ?? "", + data, + }), + }, + }); + return wireBytes(toBinary(TerminalClientFrameSchema, frame)); +} + +export type DecodedTerminalServerFrame = + | { kind: "hello"; ok: boolean; message: string } + | { kind: "frame"; header: TerminalWireHeader; data: Uint8Array }; + +export function decodeTerminalServerFrame( + data: ArrayBuffer | Uint8Array, +): DecodedTerminalServerFrame | null { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + let frame: TerminalServerFrame; + try { + frame = fromBinary(TerminalServerFrameSchema, bytes); + } catch { + return null; + } + const payload = frame.payload; + if (payload.case === "hello") { + return { + kind: "hello", + ok: payload.value.ok, + message: payload.value.message, + }; + } + if (payload.case === "frame") { + return { kind: "frame", header: terminalStreamHeader(payload.value), data: payload.value.data }; + } + return null; +} + +function terminalStreamHeader(frame: TerminalStreamFrame): TerminalWireHeader { + return { + kind: frame.kind, + streamId: frame.streamId, + sessionId: frame.sessionId, + projectPathKey: frame.projectPathKey, + seq: num(frame.seq), + startOffset: num(frame.startOffset), + endOffset: num(frame.endOffset), + cols: frame.cols, + rows: frame.rows, + maxBytes: frame.maxBytes, + truncated: frame.truncated, + error: frame.error, + session: terminalSessionPayload(frame.session) ?? undefined, + }; +} diff --git a/crates/agent-mobile/src/lib/gatewayTypes.ts b/crates/agent-mobile/src/lib/gatewayTypes.ts new file mode 100644 index 000000000..82402fb85 --- /dev/null +++ b/crates/agent-mobile/src/lib/gatewayTypes.ts @@ -0,0 +1,288 @@ +import type { ChatQueueItemDetail, ChatQueueSnapshot } from "@liveagent/ui/contracts/chatQueue"; +import type { + ChatRuntimeControls, + CodexRequestFormat, + PromptCacheHintMode, + ProviderId, + ProviderModelConfig, + ReasoningLevel, +} from "@/lib/settings"; + +export type { + ChatQueueItemDetail, + ChatQueueItemSummary, + ChatQueueSnapshot, +} from "@liveagent/ui/contracts/chatQueue"; + +export type AgentStatus = { + online: boolean; + agent_ready?: boolean; + chat_runtime_ready?: boolean; + agent_id?: string; + name?: string; + agent_version?: string; + session_id?: string; + connected_since?: number; + last_heartbeat?: number; + runtime_state?: "ready" | "draining" | "busy" | "suspended" | string; + runtime_last_heartbeat?: number; + runtime_worker_id?: string; + runtime_visible?: boolean; + runtime_active_run_count?: number; +}; + +export type GatewaySelectedModel = { + customProviderId: string; + model: string; + providerType: ProviderId; +}; + +export type GatewayChatRuntimeControls = Pick< + ChatRuntimeControls, + "thinkingEnabled" | "nativeWebSearchEnabled" | "reasoning" +>; + +export type GatewayProviderSummary = { + id: string; + name: string; + type: ProviderId; + models: ProviderModelConfig[]; + activeModels: string[]; + requestFormat?: CodexRequestFormat; + reasoning: ReasoningLevel; + promptCachingEnabled: boolean; + promptCacheHintMode?: PromptCacheHintMode; + nativeWebSearchEnabled: boolean; +}; + +export type ChatCheckpointPayload = { + summaryId?: string; + segmentIndex?: number; + coveredMessageCount?: number; + coversThroughMessageId?: string; + timestamp?: number; + generatedBy?: { + providerId?: string; + model?: string; + promptVersion?: string; + }; + contextUsageTokens?: number; +}; + +export type ChatUserMessageEvent = { + type: "user_message"; + client_request_id?: string; + conversation_id?: string; + message_id?: string; + message?: string; + uploaded_files?: unknown; + // The new message's own persisted identity (desktop mints it at persist + // time) — lets subscribers bind the turn's messageRef immediately. + message_ref?: unknown; + base_message_ref?: unknown; + reason?: string; +}; + +export type ChatRebasedEvent = { + type: "rebased"; + conversation_id?: string; + base_message_ref?: unknown; + reason?: string; +}; + +export type ChatEvent = ( + | { + type: "token"; + text: string; + title?: string; + titleFinal?: boolean; + round?: number; + provider?: string; + model?: string; + api?: string; + stopReason?: string; + usage?: unknown; + contextUsageTokens?: number; + contextRelevant?: boolean; + checkpoint?: ChatCheckpointPayload; + conversation_id?: string; + } + | { type: "thinking"; text: string; round?: number; conversation_id?: string } + | { + type: "tool_call" | "tool_call_delta"; + id?: string; + name?: string; + arguments?: unknown; + args?: unknown; + input?: unknown; + parameters?: unknown; + toolCall?: unknown; + payload?: unknown; + data?: unknown; + round?: number; + conversation_id?: string; + } + | { + type: "tool_result"; + id?: string; + name?: string; + arguments?: unknown; + content?: unknown; + details?: unknown; + isError?: boolean; + round?: number; + conversation_id?: string; + } + | { + type: "hosted_search"; + id?: string; + provider?: string; + status?: "searching" | "completed" | "failed"; + queries?: string[]; + sources?: Array<{ + url: string; + title?: string; + snippet?: string; + citedText?: string; + sourceType?: "source" | "citation"; + }>; + updatedAt?: number; + round?: number; + conversation_id?: string; + } + | { type: "done"; title?: string; round?: number; conversation_id?: string } + | { + type: "tool_status"; + status?: string | null; + isCompaction?: boolean; + // Stream-retry history of the live run: null/absent = unchanged, an + // array (possibly empty) replaces the current list. + retryAttempts?: { attempt: number; maxAttempts: number; errorMessage: string }[] | null; + round?: number; + conversation_id?: string; + } + | { + type: "manual_compaction_result"; + operationId: string; + status: "compacted" | "failed" | "busy" | "skipped"; + message?: string; + conversation_id?: string; + } + | { type: "error"; message: string; round?: number; conversation_id?: string } + | ChatUserMessageEvent + | ChatRebasedEvent +) & { seq?: number; workdir?: string }; + +export type CronManagePayload = { + action: string; + task_id?: string; + task_json?: string; +}; + +export type ChatQueueResponse = { + accepted: boolean; + message?: string; + snapshot?: ChatQueueSnapshot; + item?: ChatQueueItemDetail; + errorCode?: string; + revision?: number; +}; + +export type CronManageResponse = { + action: string; + result_json: string; +}; + +export type MemoryManagePayload = { + command: string; + args?: unknown; +}; + +export type ConversationSummary = { + id: string; + title: string; + created_at: number; + updated_at: number; + message_count: number; + provider_id?: string; + model?: string; + session_id?: string; + cwd?: string; + selected_model_json?: string; + is_pinned?: boolean; + pinned_at?: number; + is_shared?: boolean; +}; + +export type HistoryList = { + conversations: ConversationSummary[]; + total_count: number; + running_conversations?: RunningConversationSummary[]; +}; + +// history.list `running_conversations` items — the gateway's activity +// registry snapshot at response time. +export type RunningConversationSummary = { + conversation_id: string; + run_id?: string; + state?: string; + cwd?: string; + updated_at?: number; +}; + +export type HistoryListFilter = { + cwd?: string; + cwdEmpty?: boolean; +}; + +export type HistoryWorkdirSummary = { + path: string; + conversationCount: number; + updatedAt: number; +}; + +export type HistoryWorkdirsResponse = { + workdirs: HistoryWorkdirSummary[]; +}; + +export type CreateProjectFolderResponse = { + path: string; +}; + +export type HistoryDetail = { + conversation_id: string; + messages_json: string; + total_message_count?: number; + returned_message_count?: number; + has_more?: boolean; + conversation?: ConversationSummary; +}; + +export type HistoryShareStatus = { + conversation_id: string; + enabled: boolean; + token?: string; + created_at?: number; + updated_at?: number; + redact_tool_content?: boolean; +}; + +export type SharedHistoryDetail = { + conversation_id: string; + messages_json: string; + total_message_count?: number; + conversation?: ConversationSummary; + redact_tool_content?: boolean; +}; + +export type GatewayHistoryEvent = + | { + kind: "upsert"; + conversation_id: string; + conversation: ConversationSummary; + } + | { + kind: "delete"; + conversation_id: string; + conversation?: undefined; + }; diff --git a/crates/agent-mobile/src/lib/git/gatewayGitClient.ts b/crates/agent-mobile/src/lib/git/gatewayGitClient.ts new file mode 100644 index 000000000..f1de1a56d --- /dev/null +++ b/crates/agent-mobile/src/lib/git/gatewayGitClient.ts @@ -0,0 +1,173 @@ +import { + type GitClient, + normalizeGitBranchesResponse, + normalizeGitCommitDetailsResponse, + normalizeGitDiffResponse, + normalizeGitLogResponse, + normalizeGitOperationResponse, + normalizeGitRemoveWorktreeResponse, + normalizeGitRepositoryDiscovery, + normalizeGitRepositoryState, + normalizeGitWorktreeResponse, +} from "@liveagent/ui/lib/git/types"; +import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; + +export function createGatewayGitClient(api: GatewayWebSocketClientLike): GitClient { + return { + async status(workdir) { + return normalizeGitRepositoryState(await api.gitRequest("status", workdir), workdir); + }, + async discoverRepositories(workdir) { + return normalizeGitRepositoryDiscovery( + await api.gitRequest("discover_repositories", workdir), + workdir, + ); + }, + async branches(workdir) { + return normalizeGitBranchesResponse(await api.gitRequest("branches", workdir), workdir); + }, + async init(workdir, options = {}) { + return normalizeGitOperationResponse( + await api.gitRequest("init", workdir, { + branch: options.branch, + userName: options.userName, + userEmail: options.userEmail, + }), + workdir, + ); + }, + async switchBranch(workdir, branch, kind) { + return normalizeGitOperationResponse( + await api.gitRequest("switch_branch", workdir, { branch, kind }), + workdir, + ); + }, + async createBranch(workdir, branch, startPoint) { + return normalizeGitOperationResponse( + await api.gitRequest("create_branch", workdir, { branch, startPoint }), + workdir, + ); + }, + async createWorktree(workdir, options) { + return normalizeGitWorktreeResponse( + await api.gitRequest("create_worktree", workdir, { + branch: options.branch, + directoryName: options.directoryName, + parentDirectory: options.parentDirectory, + startPoint: options.startPoint, + }), + workdir, + ); + }, + async diff(workdir, mode, path) { + return normalizeGitDiffResponse(await api.gitRequest("diff", workdir, { mode, path })); + }, + async log(workdir, options = {}) { + return normalizeGitLogResponse( + await api.gitRequest("log", workdir, { limit: options.limit, skip: options.skip }), + workdir, + ); + }, + async commitDetails(workdir, commit) { + return normalizeGitCommitDetailsResponse( + await api.gitRequest("commit_details", workdir, { commit }), + workdir, + ); + }, + async compareCommitWithRemote(workdir, commit) { + return normalizeGitDiffResponse( + await api.gitRequest("compare_commit_with_remote", workdir, { commit }), + ); + }, + async commitDiff(workdir, commit, path) { + return normalizeGitDiffResponse( + await api.gitRequest("commit_diff", workdir, { commit, path }), + ); + }, + async stage(workdir, path) { + return normalizeGitOperationResponse( + await api.gitRequest("stage", workdir, { path }), + workdir, + ); + }, + async stageAll(workdir) { + return normalizeGitOperationResponse(await api.gitRequest("stage_all", workdir), workdir); + }, + async unstage(workdir, path) { + return normalizeGitOperationResponse( + await api.gitRequest("unstage", workdir, { path }), + workdir, + ); + }, + async unstageAll(workdir) { + return normalizeGitOperationResponse(await api.gitRequest("unstage_all", workdir), workdir); + }, + async discard(workdir, path, oldPath) { + return normalizeGitOperationResponse( + await api.gitRequest("discard", workdir, { path, oldPath }), + workdir, + ); + }, + async discardAll(workdir) { + return normalizeGitOperationResponse(await api.gitRequest("discard_all", workdir), workdir); + }, + async addToGitignore(workdir, path) { + return normalizeGitOperationResponse( + await api.gitRequest("add_to_gitignore", workdir, { path }), + workdir, + ); + }, + async commit(workdir, message) { + return normalizeGitOperationResponse( + await api.gitRequest("commit", workdir, { message }), + workdir, + ); + }, + async fetch(workdir) { + return normalizeGitOperationResponse(await api.gitRequest("fetch", workdir), workdir); + }, + async pull(workdir) { + return normalizeGitOperationResponse(await api.gitRequest("pull", workdir), workdir); + }, + async setRemote(workdir, remoteUrl) { + return normalizeGitOperationResponse( + await api.gitRequest("set_remote", workdir, { remoteUrl }), + workdir, + ); + }, + async push(workdir) { + return normalizeGitOperationResponse(await api.gitRequest("push", workdir), workdir); + }, + async deleteBranch(workdir, branch, force) { + return normalizeGitOperationResponse( + await api.gitRequest("delete_branch", workdir, { branch, force }), + workdir, + ); + }, + async renameBranch(workdir, branch, newBranch) { + return normalizeGitOperationResponse( + await api.gitRequest("rename_branch", workdir, { branch, newBranch }), + workdir, + ); + }, + async removeWorktree(workdir, worktreePath, options = {}) { + return normalizeGitRemoveWorktreeResponse( + await api.gitRequest("remove_worktree", workdir, { + worktreePath, + force: options.force, + deleteBranch: options.deleteBranch, + }), + workdir, + ); + }, + async stashPush(workdir, message) { + return normalizeGitOperationResponse( + await api.gitRequest("stash_push", workdir, { message }), + workdir, + ); + }, + async stashPop(workdir) { + return normalizeGitOperationResponse(await api.gitRequest("stash_pop", workdir), workdir); + }, + }; +} diff --git a/crates/agent-mobile/src/lib/historyParser.ts b/crates/agent-mobile/src/lib/historyParser.ts new file mode 100644 index 000000000..2882a9861 --- /dev/null +++ b/crates/agent-mobile/src/lib/historyParser.ts @@ -0,0 +1,127 @@ +import { type ChatEntry, parseHistoryMessagesJson } from "./chatUi"; + +type HistoryParseWorkerResponse = + | { + type: "parsed-history"; + requestId: string; + entries: ChatEntry[]; + } + | { + type: "parse-history-error"; + requestId: string; + message: string; + }; + +type PendingParse = { + resolve: (entries: ChatEntry[]) => void; + reject: (error: Error) => void; +}; + +const HISTORY_PARSE_WORKER_MIN_CHARS = 512 * 1024; + +let parseRequestSeq = 0; +let parserWorker: Worker | null = null; +let parserWorkerFailed = false; +const pendingParses = new Map(); + +function yieldToMainThread() { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, 0); + }); +} + +function rejectPendingParses(error: Error) { + for (const pending of pendingParses.values()) { + pending.reject(error); + } + pendingParses.clear(); +} + +function disposeParserWorker(error?: Error) { + if (parserWorker) { + parserWorker.terminate(); + } + parserWorker = null; + if (error) { + parserWorkerFailed = true; + rejectPendingParses(error); + } +} + +function getParserWorker() { + if (parserWorkerFailed || typeof Worker === "undefined") { + return null; + } + if (parserWorker) { + return parserWorker; + } + + try { + const worker = new Worker(new URL("./historyParser.worker.ts", import.meta.url), { + type: "module", + }); + worker.onmessage = (event: MessageEvent) => { + const response = event.data; + const pending = pendingParses.get(response.requestId); + if (!pending) { + return; + } + pendingParses.delete(response.requestId); + + if (response.type === "parsed-history") { + pending.resolve(response.entries); + return; + } + pending.reject(new Error(response.message || "history parse worker failed")); + }; + worker.onerror = (event) => { + disposeParserWorker(new Error(event.message || "history parse worker failed")); + }; + parserWorker = worker; + return parserWorker; + } catch { + parserWorkerFailed = true; + return null; + } +} + +function parseHistoryMessagesJsonInWorker(raw: string) { + const worker = getParserWorker(); + if (!worker) { + return null; + } + + parseRequestSeq += 1; + const requestId = `history-parse-${Date.now()}-${parseRequestSeq}`; + return new Promise((resolve, reject) => { + pendingParses.set(requestId, { resolve, reject }); + try { + worker.postMessage({ + type: "parse-history", + requestId, + raw, + }); + } catch (error) { + pendingParses.delete(requestId); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); +} + +export async function parseHistoryMessagesJsonAsync(raw: string): Promise { + if (raw.length < HISTORY_PARSE_WORKER_MIN_CHARS) { + return parseHistoryMessagesJson(raw); + } + + const workerParse = parseHistoryMessagesJsonInWorker(raw); + if (workerParse) { + try { + return await workerParse; + } catch { + // Fall through to the sync parser so history still opens if Worker startup fails. + } + } + + await yieldToMainThread(); + return parseHistoryMessagesJson(raw); +} diff --git a/crates/agent-mobile/src/lib/historyParser.worker.ts b/crates/agent-mobile/src/lib/historyParser.worker.ts new file mode 100644 index 000000000..dfa54e686 --- /dev/null +++ b/crates/agent-mobile/src/lib/historyParser.worker.ts @@ -0,0 +1,47 @@ +import { type ChatEntry, parseHistoryMessagesJson } from "./chatUi"; + +type HistoryParseWorkerRequest = { + type: "parse-history"; + requestId: string; + raw: string; +}; + +type HistoryParseWorkerResponse = + | { + type: "parsed-history"; + requestId: string; + entries: ChatEntry[]; + } + | { + type: "parse-history-error"; + requestId: string; + message: string; + }; + +function asErrorMessage(error: unknown) { + return error instanceof Error && error.message.trim() + ? error.message.trim() + : String(error ?? "unknown error"); +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + if (!message || message.type !== "parse-history") { + return; + } + + try { + const entries = parseHistoryMessagesJson(message.raw); + self.postMessage({ + type: "parsed-history", + requestId: message.requestId, + entries, + } satisfies HistoryParseWorkerResponse); + } catch (error) { + self.postMessage({ + type: "parse-history-error", + requestId: message.requestId, + message: asErrorMessage(error), + } satisfies HistoryParseWorkerResponse); + } +}; diff --git a/crates/agent-mobile/src/lib/historyShare.ts b/crates/agent-mobile/src/lib/historyShare.ts new file mode 100644 index 000000000..184739fe0 --- /dev/null +++ b/crates/agent-mobile/src/lib/historyShare.ts @@ -0,0 +1,108 @@ +import { getGatewayOrigin } from "./gatewayOrigin"; +import type { SharedHistoryDetail } from "./gatewayTypes"; + +const SHARE_PATH_PREFIX = "/share/"; + +export function normalizeHistoryTimestampMs(timestamp?: number | null) { + if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) { + return null; + } + + if (timestamp < 100_000_000_000) { + return timestamp * 1000; + } + if (timestamp < 100_000_000_000_000) { + return timestamp; + } + if (timestamp < 100_000_000_000_000_000) { + return Math.floor(timestamp / 1000); + } + return Math.floor(timestamp / 1_000_000); +} + +export function formatSharedHistoryTimestamp(timestamp?: number | null) { + const normalized = normalizeHistoryTimestampMs(timestamp); + if (normalized === null) { + return ""; + } + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(normalized)); +} + +function getWindowLocationPathname() { + if (typeof window === "undefined") { + return ""; + } + return window.location.pathname; +} + +function getWindowLocationOrigin() { + if (typeof window === "undefined") { + return ""; + } + return window.location.origin; +} + +export function parseHistoryShareToken(pathname = getWindowLocationPathname()) { + const normalized = pathname.trim(); + if (!normalized.startsWith(SHARE_PATH_PREFIX)) { + return null; + } + + const rest = normalized.slice(SHARE_PATH_PREFIX.length); + if (!rest || rest.includes("/")) { + return null; + } + + try { + const token = decodeURIComponent(rest).trim(); + return token || null; + } catch { + return null; + } +} + +export function buildHistoryShareUrl(token: string, origin = getWindowLocationOrigin()) { + const normalizedToken = token.trim(); + if (!normalizedToken) { + return ""; + } + return `${origin.replace(/\/$/, "")}${SHARE_PATH_PREFIX}${encodeURIComponent(normalizedToken)}`; +} + +export async function fetchSharedHistory(token: string): Promise { + const normalizedToken = token.trim(); + if (!normalizedToken) { + throw new Error("分享链接无效"); + } + + const response = await fetch( + `${getGatewayOrigin()}/api/public/history-shares/${encodeURIComponent(normalizedToken)}`, + { + credentials: "omit", + headers: { + Accept: "application/json", + }, + }, + ); + + if (!response.ok) { + let message = response.status === 404 ? "分享链接不存在或已关闭" : "读取分享会话失败"; + try { + const payload = (await response.json()) as { error?: unknown }; + if (typeof payload.error === "string" && payload.error.trim()) { + message = payload.error.trim(); + } + } catch { + // Keep the status-derived message. + } + throw new Error(message); + } + + return (await response.json()) as SharedHistoryDetail; +} diff --git a/crates/agent-mobile/src/lib/managed-process/backend.ts b/crates/agent-mobile/src/lib/managed-process/backend.ts new file mode 100644 index 000000000..29c5c55ee --- /dev/null +++ b/crates/agent-mobile/src/lib/managed-process/backend.ts @@ -0,0 +1,66 @@ +// Web transport for the ManagedProcess store: panel operations are relayed +// through the gateway's process.* websocket protocol to the desktop-owned +// registry, and state arrives via the process.state broadcast (replayed on +// connect from the gateway cache). This file is the per-platform adapter — +// the desktop frontend ships its own copy speaking Tauri invoke. + +import type { + ManagedProcessBackend, + ManagedProcessState, +} from "@liveagent/ui/lib/managed-process/types"; +import { + getGatewayWebSocketClient, + type ManagedProcessStatePayload, + onGatewayWebSocketClientReplaced, +} from "../gatewaySocket"; +import { loadToken } from "../storage"; + +function client() { + return getGatewayWebSocketClient(loadToken().trim()); +} + +function toState(payload: ManagedProcessStatePayload): ManagedProcessState { + return { ready: true, ...payload }; +} + +export const backend: ManagedProcessBackend = { + async fetchState(): Promise { + return toState(await client().processSnapshot()); + }, + + async stop(id: string): Promise { + const state = await client().processStop(id); + return state ? toState(state) : null; + }, + + async clear(id?: string): Promise { + const state = await client().processClear(id); + return state ? toState(state) : null; + }, + + readLog(id: string, maxBytes?: number) { + return client().processReadLog(id, maxBytes); + }, + + subscribe(onState: (state: ManagedProcessState) => void): () => void { + const handler = (state: ManagedProcessStatePayload) => { + onState(toState(state)); + }; + // Subscriptions live on one client instance; when the singleton is + // replaced (token change) re-attach to the new instance and refetch the + // state missed during the swap. + let detach = client().subscribeProcessState(handler); + const detachReplaced = onGatewayWebSocketClientReplaced(() => { + detach(); + detach = client().subscribeProcessState(handler); + client() + .processSnapshot() + .then(handler) + .catch(() => {}); + }); + return () => { + detach(); + detachReplaced(); + }; + }, +}; diff --git a/crates/agent-mobile/src/lib/proto/gen/proto/v2/gateway_pb.ts b/crates/agent-mobile/src/lib/proto/gen/proto/v2/gateway_pb.ts new file mode 100644 index 000000000..8915f37f0 --- /dev/null +++ b/crates/agent-mobile/src/lib/proto/gen/proto/v2/gateway_pb.ts @@ -0,0 +1,5978 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated from file proto/v2/gateway.proto (package liveagent.gateway.v2, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file proto/v2/gateway.proto. + */ +export const file_proto_v2_gateway: GenFile = /*@__PURE__*/ + fileDesc("ChZwcm90by92Mi9nYXRld2F5LnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MiL9GwoPR2F0ZXdheUVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEkAKDGNoYXRfY29tbWFuZBgKIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRDb21tYW5kUmVxdWVzdEgAEj4KC2Nyb25fbWFuYWdlGBQgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ3Jvbk1hbmFnZVJlcXVlc3RIABJACgxoaXN0b3J5X2xpc3QYHiABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5TGlzdFJlcXVlc3RIABI+CgtoaXN0b3J5X2dldBgfIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlHZXRSZXF1ZXN0SAASRAoOaGlzdG9yeV9yZW5hbWUYICABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UmVuYW1lUmVxdWVzdEgAEkQKDmhpc3RvcnlfZGVsZXRlGCEgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeURlbGV0ZVJlcXVlc3RIABJECg5oaXN0b3J5X3ByZWZpeBgiIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlQcmVmaXhSZXF1ZXN0SAASPgoLaGlzdG9yeV9waW4YIyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UGluUmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfZ2V0GCQgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlR2V0UmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfc2V0GCUgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlU2V0UmVxdWVzdEgAElEKFWhpc3Rvcnlfc2hhcmVfcmVzb2x2ZRgmIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVJlc29sdmVSZXF1ZXN0SAASSAoQaGlzdG9yeV93b3JrZGlycxgnIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlXb3JrZGlyc1JlcXVlc3RIABJCCg1wcm92aWRlcl9saXN0GCggASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuUHJvdmlkZXJMaXN0UmVxdWVzdEgAEkAKDHNldHRpbmdzX2dldBgpIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzR2V0UmVxdWVzdEgAEkYKD3NldHRpbmdzX3VwZGF0ZRgqIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzVXBkYXRlUmVxdWVzdEgAEkcKEHNraWxsX2ZpbGVzX2xpc3QYKyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Ta2lsbEZpbGVzTGlzdFJlcXVlc3RIABJNChNza2lsbF9tZXRhZGF0YV9yZWFkGCwgASgLMi4ubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxNZXRhZGF0YVJlYWRSZXF1ZXN0SAASRQoPc2tpbGxfdGV4dF9yZWFkGC0gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxUZXh0UmVhZFJlcXVlc3RIABJJChFmaWxlX21lbnRpb25fbGlzdBguIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZpbGVNZW50aW9uTGlzdFJlcXVlc3RIABJRChV1cGxvYWRfcmVhZGFibGVfZmlsZXMYLyABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdEgAEjgKCGZzX3Jvb3RzGDAgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNSb290c1JlcXVlc3RIABI/Cgxmc19saXN0X2RpcnMYMSABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0xpc3REaXJzUmVxdWVzdEgAEjEKBHBpbmcYMiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5QaW5nUmVxdWVzdEgAElMKFnVwbG9hZGVkX2ltYWdlX3ByZXZpZXcYMyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3RIABJCCg1tZW1vcnlfbWFuYWdlGDQgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuTWVtb3J5TWFuYWdlUmVxdWVzdEgAEkAKDHNraWxsX21hbmFnZRg1IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsTWFuYWdlUmVxdWVzdEgAElYKGGZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlchg2IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzQ3JlYXRlUHJvamVjdEZvbGRlclJlcXVlc3RIABJBChB0ZXJtaW5hbF9yZXF1ZXN0GDcgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxSZXF1ZXN0SAASNgoHZnNfbGlzdBg4IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzTGlzdFJlcXVlc3RIABJBCg1mc193cml0ZV90ZXh0GDkgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNXcml0ZVRleHRSZXF1ZXN0SAASQQoNZnNfY3JlYXRlX2Rpchg6IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzQ3JlYXRlRGlyUmVxdWVzdEgAEjoKCWZzX3JlbmFtZRg7IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVuYW1lUmVxdWVzdEgAEjoKCWZzX2RlbGV0ZRg8IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzRGVsZXRlUmVxdWVzdEgAEjcKC2dpdF9yZXF1ZXN0GD0gASgLMiAubGl2ZWFnZW50LmdhdGV3YXkudjIuR2l0UmVxdWVzdEgAElAKFWZzX3JlYWRfZWRpdGFibGVfdGV4dBg+IAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZEVkaXRhYmxlVGV4dFJlcXVlc3RIABJUChdmc19yZWFkX3dvcmtzcGFjZV9pbWFnZRg/IAEoCzIxLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVxdWVzdEgAEjkKDHNmdHBfcmVxdWVzdBhAIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBSZXF1ZXN0SAASRgoPcHJvdmlkZXJfbW9kZWxzGEEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuUHJvdmlkZXJNb2RlbHNSZXF1ZXN0SAASXwodc2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3QYSCABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVxdWVzdEgAEjwKCmNoYXRfcXVldWUYSSABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UXVldWVSZXF1ZXN0SAASQAoQY2hhdF9pbmdyZXNzX2FjaxhLIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzQWNrSAASQQoMdHVubmVsX3N0YXRlGFAgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsU3RhdGVTbmFwc2hvdEgAEj8KD3R1bm5lbF9tdXRhdGlvbhhRIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbE11dGF0aW9uSAASOQoMdHVubmVsX2ZyYW1lGFIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsRnJhbWVIABJGCg93b3Jrc3BhY2Vfd2F0Y2gYWiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VXYXRjaFJlcXVlc3RIABJOChdtYW5hZ2VkX3Byb2Nlc3NfcmVxdWVzdBhbIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzUmVxdWVzdEgAEkQKDmhpc3RvcnlfYnJhbmNoGFwgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeUJyYW5jaFJlcXVlc3RIABJECg5wcm92aWRlcl91c2FnZRhdIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyVXNhZ2VSZXF1ZXN0SAASQwoOY2hhdF9maWxlX29wZW4YXiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RmlsZU9wZW5SZXF1ZXN0SAASRQoPaGlzdG9yeV9zZXRfY3dkGF8gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNldEN3ZFJlcXVlc3RIAEIJCgdwYXlsb2FkSgQIQxBESgQIRBBFSgQIRRBGSgQIShBLIuckCg1BZ2VudEVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEjUKCmNoYXRfZXZlbnQYCiABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RXZlbnRIABJEChBjcm9uX21hbmFnZV9yZXNwGBQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuQ3Jvbk1hbmFnZVJlc3BvbnNlSAASRgoRaGlzdG9yeV9saXN0X3Jlc3AYHiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5TGlzdFJlc3BvbnNlSAASRAoQaGlzdG9yeV9nZXRfcmVzcBgfIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlHZXRSZXNwb25zZUgAEkoKE2hpc3RvcnlfcmVuYW1lX3Jlc3AYICABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UmVuYW1lUmVzcG9uc2VIABJKChNoaXN0b3J5X2RlbGV0ZV9yZXNwGCEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeURlbGV0ZVJlc3BvbnNlSAASPgoMaGlzdG9yeV9zeW5jGCIgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVN5bmNFdmVudEgAEkoKE2hpc3RvcnlfcHJlZml4X3Jlc3AYIyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UHJlZml4UmVzcG9uc2VIABJEChBoaXN0b3J5X3Bpbl9yZXNwGCQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVBpblJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9nZXRfcmVzcBglIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZUdldFJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9zZXRfcmVzcBgmIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVNldFJlc3BvbnNlSAASVwoaaGlzdG9yeV9zaGFyZV9yZXNvbHZlX3Jlc3AYJyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5U2hhcmVSZXNvbHZlUmVzcG9uc2VIABJOChVoaXN0b3J5X3dvcmtkaXJzX3Jlc3AYOCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5V29ya2RpcnNSZXNwb25zZUgAEkgKEnByb3ZpZGVyX2xpc3RfcmVzcBgoIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyTGlzdFJlc3BvbnNlSAASRgoRc2V0dGluZ3NfZ2V0X3Jlc3AYKSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc0dldFJlc3BvbnNlSAASTAoUc2V0dGluZ3NfdXBkYXRlX3Jlc3AYKiABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc1VwZGF0ZVJlc3BvbnNlSAASQAoNc2V0dGluZ3Nfc3luYxgrIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzU3luY0V2ZW50SAASTQoVc2tpbGxfZmlsZXNfbGlzdF9yZXNwGCwgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxGaWxlc0xpc3RSZXNwb25zZUgAElMKGHNraWxsX21ldGFkYXRhX3JlYWRfcmVzcBgtIAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2VIABJLChRza2lsbF90ZXh0X3JlYWRfcmVzcBguIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsVGV4dFJlYWRSZXNwb25zZUgAEk8KFmZpbGVfbWVudGlvbl9saXN0X3Jlc3AYLyABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5GaWxlTWVudGlvbkxpc3RSZXNwb25zZUgAElcKGnVwbG9hZF9yZWFkYWJsZV9maWxlc19yZXNwGDAgASgLMjEubGl2ZWFnZW50LmdhdGV3YXkudjIuVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlSAASPgoNZnNfcm9vdHNfcmVzcBgxIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUm9vdHNSZXNwb25zZUgAEjIKBHBvbmcYMiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Qb25nUmVzcG9uc2VIABJFChFmc19saXN0X2RpcnNfcmVzcBgzIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzTGlzdERpcnNSZXNwb25zZUgAElkKG3VwbG9hZGVkX2ltYWdlX3ByZXZpZXdfcmVzcBg0IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLlVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2VIABJIChJtZW1vcnlfbWFuYWdlX3Jlc3AYNSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NZW1vcnlNYW5hZ2VSZXNwb25zZUgAEkYKEXNraWxsX21hbmFnZV9yZXNwGDYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxNYW5hZ2VSZXNwb25zZUgAElwKHWZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlcl9yZXNwGDcgASgLMjMubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVzcG9uc2VIABJDChF0ZXJtaW5hbF9yZXNwb25zZRg5IAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsUmVzcG9uc2VIABI9Cg50ZXJtaW5hbF9ldmVudBg6IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsRXZlbnRIABI8Cgxmc19saXN0X3Jlc3AYOyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0xpc3RSZXNwb25zZUgAEkcKEmZzX3dyaXRlX3RleHRfcmVzcBg8IAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzV3JpdGVUZXh0UmVzcG9uc2VIABJHChJmc19jcmVhdGVfZGlyX3Jlc3AYPSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0NyZWF0ZURpclJlc3BvbnNlSAASQAoOZnNfcmVuYW1lX3Jlc3AYPiABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc1JlbmFtZVJlc3BvbnNlSAASQAoOZnNfZGVsZXRlX3Jlc3AYPyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0RlbGV0ZVJlc3BvbnNlSAASOQoMZ2l0X3Jlc3BvbnNlGEAgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuR2l0UmVzcG9uc2VIABJWChpmc19yZWFkX2VkaXRhYmxlX3RleHRfcmVzcBhBIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlSAASWgocZnNfcmVhZF93b3Jrc3BhY2VfaW1hZ2VfcmVzcBhCIAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVzcG9uc2VIABI7Cg1zZnRwX3Jlc3BvbnNlGEkgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuU2Z0cFJlc3BvbnNlSAASNQoKc2Z0cF9ldmVudBhKIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBFdmVudEgAEkIKD2NoYXRfcXVldWVfcmVzcBhLIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRRdWV1ZVJlc3BvbnNlSAASQAoQY2hhdF9xdWV1ZV9ldmVudBhMIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRRdWV1ZUV2ZW50SAASPgoMY2hhdF9jb250cm9sGEYgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdENvbnRyb2xFdmVudEgAEkIKDnJ1bnRpbWVfc3RhdHVzGEcgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuUnVudGltZVN0YXR1c0V2ZW50SAASZQoic2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3RfcmVzcBhIIAEoCzI3LmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZUgAEkoKFWNoYXRfcnVudGltZV9zbmFwc2hvdBhNIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW50aW1lU25hcHNob3RIABJMChRwcm92aWRlcl9tb2RlbHNfcmVzcBhPIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyTW9kZWxzUmVzcG9uc2VIABJCCg50dW5uZWxfZGVzaXJlZBhQIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbERlc2lyZWRTdGF0ZUgAEkwKFnR1bm5lbF9tdXRhdGlvbl9yZXN1bHQYUSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxNdXRhdGlvblJlc3VsdEgAEjkKDHR1bm5lbF9mcmFtZRhSIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEZyYW1lSAASRgoTdHVubmVsX3Byb2JlX3JlcG9ydBhTIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbFByb2JlUmVwb3J0SAASSgoSd29ya3NwYWNlX2FjdGl2aXR5GFogASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuV29ya3NwYWNlQWN0aXZpdHlFdmVudEgAElAKGG1hbmFnZWRfcHJvY2Vzc19yZXNwb25zZRhbIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzUmVzcG9uc2VIABJQChhtYW5hZ2VkX3Byb2Nlc3Nfc25hcHNob3QYXCABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NYW5hZ2VkUHJvY2Vzc1NuYXBzaG90SAASSgoTaGlzdG9yeV9icmFuY2hfcmVzcBhdIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlCcmFuY2hSZXNwb25zZUgAEkoKE3Byb3ZpZGVyX3VzYWdlX3Jlc3AYXiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Qcm92aWRlclVzYWdlUmVzcG9uc2VIABJEChJjaGF0X2luZ3Jlc3NfYmF0Y2gYXyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0JhdGNoSAASRgoTY2hhdF9pbmdyZXNzX3Jlc3VtZRhgIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzUmVzdW1lSAASSgoVY2hhdF9pbmdyZXNzX2ZyYWdtZW50GGEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NGcmFnbWVudEgAEkkKE2NoYXRfZmlsZV9vcGVuX3Jlc3AYYiABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RmlsZU9wZW5SZXNwb25zZUgAEjQKBWVycm9yGGMgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjIuRXJyb3JSZXNwb25zZUgAEksKFGhpc3Rvcnlfc2V0X2N3ZF9yZXNwGGQgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNldEN3ZFJlc3BvbnNlSABCCQoHcGF5bG9hZEoECEMQREoECEQQRUoECEUQRkoECE4QTyJVChFDaGF0U2VsZWN0ZWRNb2RlbBIaChJjdXN0b21fcHJvdmlkZXJfaWQYASABKAkSDQoFbW9kZWwYAiABKAkSFQoNcHJvdmlkZXJfdHlwZRgDIAEoCSJlChNDaGF0UnVudGltZUNvbnRyb2xzEhgKEHRoaW5raW5nX2VuYWJsZWQYASABKAgSIQoZbmF0aXZlX3dlYl9zZWFyY2hfZW5hYmxlZBgCIAEoCBIRCglyZWFzb25pbmcYAyABKAkidQoQQ2hhdFVwbG9hZGVkRmlsZRIVCg1yZWxhdGl2ZV9wYXRoGAEgASgJEhUKDWFic29sdXRlX3BhdGgYAiABKAkSEQoJZmlsZV9uYW1lGAMgASgJEgwKBGtpbmQYBCABKAkSEgoKc2l6ZV9ieXRlcxgFIAEoAyJLChJVcGxvYWRSZWFkYWJsZUZpbGUSEQoJZmlsZV9uYW1lGAEgASgJEhEKCW1pbWVfdHlwZRgCIAEoCRIPCgdjb250ZW50GAMgASgMImYKGlVwbG9hZFJlYWRhYmxlRmlsZXNSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSNwoFZmlsZXMYAiADKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRSZWFkYWJsZUZpbGUiZQobVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlEjUKBWZpbGVzGAEgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFVwbG9hZGVkRmlsZRIPCgdza2lwcGVkGAIgAygJIkUKG1VwbG9hZGVkSW1hZ2VQcmV2aWV3UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEhUKDWFic29sdXRlX3BhdGgYAiABKAkiPwocVXBsb2FkZWRJbWFnZVByZXZpZXdSZXNwb25zZRIRCgltaW1lX3R5cGUYASABKAkSDAoEZGF0YRgCIAEoCSJ7CgpUdW5uZWxTcGVjEgoKAmlkGAEgASgJEhEKCXNsdWdfaGludBgCIAEoCRIMCgRuYW1lGAMgASgJEhIKCnRhcmdldF91cmwYBCABKAkSEgoKZXhwaXJlc19hdBgFIAEoAxIYChBwcm9qZWN0X3BhdGhfa2V5GAYgASgJIlkKElR1bm5lbERlc2lyZWRTdGF0ZRIxCgd0dW5uZWxzGAEgAygLMiAubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsU3BlYxIQCghyZXZpc2lvbhgCIAEoBCJmCgxUdW5uZWxIZWFsdGgSDgoGc3RhdHVzGAEgASgJEhMKC2h0dHBfc3RhdHVzGAIgASgNEg0KBWVycm9yGAMgASgJEhIKCmNoZWNrZWRfYXQYBCABKAMSDgoGcnR0X21zGAUgASgNIvABCgxUdW5uZWxTdGF0dXMSCgoCaWQYASABKAkSDAoEc2x1ZxgCIAEoCRIMCgRuYW1lGAMgASgJEhIKCnRhcmdldF91cmwYBCABKAkSEwoLcHVibGljX3BhdGgYBSABKAkSEgoKY3JlYXRlZF9hdBgGIAEoAxISCgpleHBpcmVzX2F0GAcgASgDEhoKEmFjdGl2ZV9jb25uZWN0aW9ucxgIIAEoDRIYChBwcm9qZWN0X3BhdGhfa2V5GAkgASgJEjEKBWxvY2FsGAogASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsSGVhbHRoIqUBChNUdW5uZWxTdGF0ZVNuYXBzaG90EjMKB3R1bm5lbHMYASADKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxTdGF0dXMSEAoIcmV2aXNpb24YAiABKAQSFAoMYWdlbnRfb25saW5lGAMgASgIEjEKBXJlbGF5GAQgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsSGVhbHRoIpkBCg5UdW5uZWxNdXRhdGlvbhIOCgZhY3Rpb24YASABKAkSEQoJdHVubmVsX2lkGAIgASgJEhIKCnRhcmdldF91cmwYAyABKAkSDAoEbmFtZRgEIAEoCRIYCgt0dGxfc2Vjb25kcxgFIAEoDUgAiAEBEhgKEHByb2plY3RfcGF0aF9rZXkYBiABKAlCDgoMX3R0bF9zZWNvbmRzIlQKFFR1bm5lbE11dGF0aW9uUmVzdWx0EhEKCXR1bm5lbF9pZBgBIAEoCRISCgplcnJvcl9jb2RlGAIgASgJEhUKDWVycm9yX21lc3NhZ2UYAyABKAkiWQoRVHVubmVsUHJvYmVSZXN1bHQSEQoJdHVubmVsX2lkGAEgASgJEjEKBWxvY2FsGAIgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsSGVhbHRoIk0KEVR1bm5lbFByb2JlUmVwb3J0EjgKB3Jlc3VsdHMYASADKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxQcm9iZVJlc3VsdCIrCgxUdW5uZWxIZWFkZXISDAoEbmFtZRgBIAEoCRINCgV2YWx1ZRgCIAEoCSL1AgoLVHVubmVsRnJhbWUSEQoJc3RyZWFtX2lkGAEgASgJEjMKBGtpbmQYAiABKA4yJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxGcmFtZUtpbmQSEgoKdGFyZ2V0X3VybBgDIAEoCRIOCgZtZXRob2QYBCABKAkSDAoEcGF0aBgFIAEoCRIzCgdoZWFkZXJzGAYgAygLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsSGVhZGVyEg4KBnN0YXR1cxgHIAEoDRIMCgRib2R5GAggASgMEg0KBWVycm9yGAkgASgJEkIKD3dzX21lc3NhZ2VfdHlwZRgKIAEoDjIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbFdzTWVzc2FnZVR5cGUSFgoOd3Nfc3VicHJvdG9jb2wYCyABKAkSFQoNd3NfY2xvc2VfY29kZRgMIAEoDRIXCg93c19jbG9zZV9yZWFzb24YDSABKAkiKQoVV29ya3NwYWNlV2F0Y2hSZXF1ZXN0EhAKCHdvcmtkaXJzGAEgAygJIn4KFldvcmtzcGFjZUFjdGl2aXR5RXZlbnQSDwoHd29ya2RpchgBIAEoCRIQCghyZXZpc2lvbhgCIAEoBBIKCgJmcxgDIAEoCBILCgNnaXQYBCABKAgSFQoNY2hhbmdlZF9wYXRocxgFIAMoCRIRCgl0cnVuY2F0ZWQYBiABKAgilgIKFE1hbmFnZWRQcm9jZXNzUmVjb3JkEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJEg8KB2NvbW1hbmQYAyABKAkSCwoDY3dkGAQgASgJEg0KBXNoZWxsGAUgASgJEgsKA3BpZBgGIAEoDRIQCghsb2dfcGF0aBgHIAEoCRISCgpzdGFydGVkX2F0GAggASgDEhgKC2ZpbmlzaGVkX2F0GAkgASgDSACIAQESFgoJZXhpdF9jb2RlGAogASgFSAGIAQESDwoHcnVubmluZxgLIAEoCBIQCghpc29sYXRlZBgMIAEoCBIQCghyZXN0b3JlZBgNIAEoCEIOCgxfZmluaXNoZWRfYXRCDAoKX2V4aXRfY29kZSJpChZNYW5hZ2VkUHJvY2Vzc1NuYXBzaG90Ej0KCXByb2Nlc3NlcxgBIAMoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzUmVjb3JkEhAKCHJldmlzaW9uGAIgASgEIk4KFU1hbmFnZWRQcm9jZXNzUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSEgoKcHJvY2Vzc19pZBgCIAEoCRIRCgltYXhfYnl0ZXMYAyABKA0itwEKFk1hbmFnZWRQcm9jZXNzUmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEj4KCHNuYXBzaG90GAIgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuTWFuYWdlZFByb2Nlc3NTbmFwc2hvdBITCgtsb2dfY29udGVudBgDIAEoCRIQCghsb2dfcGF0aBgEIAEoCRIVCg1sb2dfdHJ1bmNhdGVkGAUgASgIEg8KB3N0b3BwZWQYBiABKAgiOQoTTWVtb3J5TWFuYWdlUmVxdWVzdBIPCgdjb21tYW5kGAEgASgJEhEKCWFyZ3NfanNvbhgCIAEoCSIrChRNZW1vcnlNYW5hZ2VSZXNwb25zZRITCgtyZXN1bHRfanNvbhgBIAEoCSKYAwoPVGVybWluYWxSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAyABKAkSCwoDY3dkGAQgASgJEg0KBXNoZWxsGAUgASgJEg0KBXRpdGxlGAYgASgJEgwKBGRhdGEYByABKAkSDAoEY29scxgIIAEoDRIMCgRyb3dzGAkgASgNEhEKCW1heF9ieXRlcxgKIAEoDRITCgtzc2hfaG9zdF9pZBgLIAEoCRIRCglwcm9tcHRfaWQYDCABKAkSFQoNcHJvbXB0X2Fuc3dlchgNIAEoCRIWCg50cnVzdF9ob3N0X2tleRgOIAEoCBIUCgxzZnRwX2VuYWJsZWQYDyABKAgSDgoGdGFiX2lkGBAgASgJEhAKCHRhYl9raW5kGBEgASgJEhMKC3JlbW90ZV9ob3N0GBIgASgJEhMKC3JlbW90ZV9wb3J0GBMgASgNEhIKCmxvY2FsX3BvcnQYFCABKA0SEgoKZm9yd2FyZF9pZBgVIAEoCSKyAgoPVGVybWluYWxTZXNzaW9uEgoKAmlkGAEgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAiABKAkSCwoDY3dkGAMgASgJEg0KBXNoZWxsGAQgASgJEg0KBXRpdGxlGAUgASgJEgsKA3BpZBgGIAEoDRIMCgRjb2xzGAcgASgNEgwKBHJvd3MYCCABKA0SEgoKY3JlYXRlZF9hdBgJIAEoBBISCgp1cGRhdGVkX2F0GAogASgEEhMKC2ZpbmlzaGVkX2F0GAsgASgEEhEKCWV4aXRfY29kZRgMIAEoBRIPCgdydW5uaW5nGA0gASgIEgwKBGtpbmQYDiABKAkSNgoDc3NoGA8gASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hNZXRhZGF0YSLbAQoTVGVybWluYWxTc2hNZXRhZGF0YRIPCgdob3N0X2lkGAEgASgJEhEKCWhvc3RfbmFtZRgCIAEoCRIQCgh1c2VybmFtZRgDIAEoCRIMCgRob3N0GAQgASgJEgwKBHBvcnQYBSABKA0SEQoJYXV0aF90eXBlGAYgASgJEg4KBnN0YXR1cxgHIAEoCRIZChFyZWNvbm5lY3RfYXR0ZW1wdBgIIAEoDRIeChZyZWNvbm5lY3RfbWF4X2F0dGVtcHRzGAkgASgNEhQKDHNmdHBfZW5hYmxlZBgKIAEoCCL3AQoLU2Z0cFJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIPCgd3b3JrZGlyGAQgASgJEhIKCmxvY2FsX3BhdGgYBSABKAkSEwoLcmVtb3RlX3BhdGgYBiABKAkSEQoJZnJvbV9wYXRoGAcgASgJEg8KB3RvX3BhdGgYCCABKAkSEQoJZGlyZWN0aW9uGAkgASgJEhMKC3RhcmdldF9wYXRoGAogASgJEhEKCXJlY3Vyc2l2ZRgLIAEoCBIRCglvdmVyd3JpdGUYDCABKAgiWAoJU2Z0cEVudHJ5EgwKBHBhdGgYASABKAkSDAoEbmFtZRgCIAEoCRIMCgRraW5kGAMgASgJEhIKCnNpemVfYnl0ZXMYBCABKAQSDQoFbXRpbWUYBSABKAQi8gEKDFNmdHBUcmFuc2ZlchIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhEKCWRpcmVjdGlvbhgDIAEoCRIOCgZzdGF0dXMYBCABKAkSEwoLc291cmNlX3BhdGgYBSABKAkSEwoLdGFyZ2V0X3BhdGgYBiABKAkSFAoMY3VycmVudF9wYXRoGAcgASgJEhIKCmJ5dGVzX2RvbmUYCCABKAQSEwoLYnl0ZXNfdG90YWwYCSABKAQSEgoKZmlsZXNfZG9uZRgKIAEoDRITCgtmaWxlc190b3RhbBgLIAEoDRINCgVlcnJvchgMIAEoCSLUAQoMU2Z0cFJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRIMCgRwYXRoGAIgASgJEjAKB2VudHJpZXMYAyADKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZnRwRW50cnkSLgoFZW50cnkYBCABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZnRwRW50cnkSDgoGZXhpc3RzGAUgASgIEjQKCHRyYW5zZmVyGAYgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuU2Z0cFRyYW5zZmVyIk8KCVNmdHBFdmVudBIMCgRraW5kGAEgASgJEjQKCHRyYW5zZmVyGAIgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuU2Z0cFRyYW5zZmVyIsEBChFUZXJtaW5hbFNzaFByb21wdBIKCgJpZBgBIAEoCRIMCgRraW5kGAIgASgJEg8KB2hvc3RfaWQYAyABKAkSEQoJaG9zdF9uYW1lGAQgASgJEgwKBGhvc3QYBSABKAkSDAoEcG9ydBgGIAEoDRIPCgdtZXNzYWdlGAcgASgJEhoKEmZpbmdlcnByaW50X3NoYTI1NhgIIAEoCRIQCghrZXlfdHlwZRgJIAEoCRITCgthbnN3ZXJfZWNobxgKIAEoCCJBChNUZXJtaW5hbFNoZWxsT3B0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJEg8KB2NvbW1hbmQYAyABKAkigAEKDlRlcm1pbmFsU3NoVGFiEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIMCgRraW5kGAQgASgJEhIKCmNyZWF0ZWRfYXQYBSABKAQSEgoKdXBkYXRlZF9hdBgGIAEoBCJ/ChdUZXJtaW5hbFNzaFRhYnNTbmFwc2hvdBIYChBwcm9qZWN0X3BhdGhfa2V5GAEgASgJEjIKBHRhYnMYAiADKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaFRhYhIQCghyZXZpc2lvbhgEIAEoBEoECAMQBCL9AQoXVGVybWluYWxTc2hMb2NhbEZvcndhcmQSCgoCaWQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEhIKCmxvY2FsX2hvc3QYBCABKAkSEgoKbG9jYWxfcG9ydBgFIAEoDRIPCgdhZGRyZXNzGAYgASgJEhMKC3JlbW90ZV9ob3N0GAcgASgJEhMKC3JlbW90ZV9wb3J0GAggASgNEg4KBnN0YXR1cxgJIAEoCRISCgpjcmVhdGVkX2F0GAogASgEEhIKCnVwZGF0ZWRfYXQYCyABKAQSDQoFZXJyb3IYDCABKAkidQogVGVybWluYWxTc2hMb2NhbEZvcndhcmRzU25hcHNob3QSPwoIZm9yd2FyZHMYASADKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaExvY2FsRm9yd2FyZBIQCghyZXZpc2lvbhgCIAEoBCJ/Ch1UZXJtaW5hbFNzaExvY2FsRm9yd2FyZEFjdGlvbhI+Cgdmb3J3YXJkGAEgASgLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hMb2NhbEZvcndhcmQSEAoIcmV2aXNpb24YAiABKAQSDAoEa2luZBgDIAEoCSKnBQoQVGVybWluYWxSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSNwoIc2Vzc2lvbnMYAiADKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNlc3Npb24SNgoHc2Vzc2lvbhgDIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU2Vzc2lvbhIOCgZvdXRwdXQYBCABKAwSEQoJdHJ1bmNhdGVkGAUgASgIEkAKDXNoZWxsX29wdGlvbnMYBiADKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNoZWxsT3B0aW9uEhUKDWRlZmF1bHRfc2hlbGwYByABKAkSGwoTb3V0cHV0X3N0YXJ0X29mZnNldBgIIAEoBBIZChFvdXRwdXRfZW5kX29mZnNldBgJIAEoBBI7Cgpzc2hfcHJvbXB0GAogASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hQcm9tcHQSEgoKbGF0ZW5jeV9tcxgLIAEoDRI/Cghzc2hfdGFicxgMIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoVGFic1NuYXBzaG90ElIKEnNzaF9sb2NhbF9mb3J3YXJkcxgNIAEoCzI2LmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkc1NuYXBzaG90Ek4KEXNzaF9sb2NhbF9mb3J3YXJkGA4gASgLMjMubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hMb2NhbEZvcndhcmRBY3Rpb24SKAogc3NoX2xvY2FsX2ZvcndhcmRfcG9ydF9hdmFpbGFibGUYDyABKAgi2gIKDVRlcm1pbmFsRXZlbnQSDAoEa2luZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAyABKAkSNgoHc2Vzc2lvbhgEIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU2Vzc2lvbhIMCgRkYXRhGAUgASgMEhsKE291dHB1dF9zdGFydF9vZmZzZXQYBiABKAQSGQoRb3V0cHV0X2VuZF9vZmZzZXQYByABKAQSPwoIc3NoX3RhYnMYCCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaFRhYnNTbmFwc2hvdBJOChFzc2hfbG9jYWxfZm9yd2FyZBgJIAEoCzIzLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkQWN0aW9uIrICChNUZXJtaW5hbFN0cmVhbUZyYW1lEgwKBGtpbmQYASABKAkSEQoJc3RyZWFtX2lkGAIgASgJEhIKCnNlc3Npb25faWQYAyABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgEIAEoCRILCgNzZXEYBSABKAQSFAoMc3RhcnRfb2Zmc2V0GAYgASgEEhIKCmVuZF9vZmZzZXQYByABKAQSDAoEY29scxgIIAEoDRIMCgRyb3dzGAkgASgNEhEKCW1heF9ieXRlcxgKIAEoDRIRCgl0cnVuY2F0ZWQYCyABKAgSDQoFZXJyb3IYDCABKAkSNgoHc2Vzc2lvbhgNIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU2Vzc2lvbhIMCgRkYXRhGA4gASgMIkAKCkdpdFJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEg8KB3dvcmtkaXIYAiABKAkSEQoJYXJnc19qc29uGAMgASgJIjIKC0dpdFJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRITCgtyZXN1bHRfanNvbhgCIAEoCSL0AgoLQ2hhdFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSPwoOc2VsZWN0ZWRfbW9kZWwYAyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0U2VsZWN0ZWRNb2RlbBIWCg5leGVjdXRpb25fbW9kZRgEIAEoCRIPCgd3b3JrZGlyGAUgASgJEj4KDnVwbG9hZGVkX2ZpbGVzGAcgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFVwbG9hZGVkRmlsZRIZChFjbGllbnRfcmVxdWVzdF9pZBgIIAEoCRJDChBydW50aW1lX2NvbnRyb2xzGAkgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFJ1bnRpbWVDb250cm9scxIUCgxxdWV1ZV9wb2xpY3kYCiABKAlKBAgGEAdSFXNlbGVjdGVkX3N5c3RlbV90b29scyKKAQoOQ2hhdE1lc3NhZ2VSZWYSFQoNc2VnbWVudF9pbmRleBgBIAEoBRIVCg1tZXNzYWdlX2luZGV4GAIgASgFEhIKCnNlZ21lbnRfaWQYAyABKAkSEgoKbWVzc2FnZV9pZBgEIAEoCRIMCgRyb2xlGAUgASgJEhQKDGNvbnRlbnRfaGFzaBgGIAEoCSI8ChFDYW5jZWxDaGF0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDgoGcnVuX2lkGAIgASgJIs8BChJDaGF0Q29tbWFuZFJlcXVlc3QSDAoEdHlwZRgBIAEoCRIyCgdyZXF1ZXN0GAIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFJlcXVlc3QSPgoQYmFzZV9tZXNzYWdlX3JlZhgDIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRNZXNzYWdlUmVmEjcKBmNhbmNlbBgEIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNhbmNlbENoYXRSZXF1ZXN0IrgBChBDaGF0UXVldWVSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDwoHaXRlbV9pZBgDIAEoCRIRCglkaXJlY3Rpb24YBCABKAkSEAoIcmV2aXNpb24YBSABKAQSEgoKZHJhZnRfanNvbhgGIAEoCRIbChN1cGxvYWRlZF9maWxlc19qc29uGAcgASgJEhQKDHJlcXVlc3RfanNvbhgIIAEoCSKGAQoRQ2hhdFF1ZXVlUmVzcG9uc2USEAoIYWNjZXB0ZWQYASABKAgSDwoHbWVzc2FnZRgCIAEoCRIVCg1zbmFwc2hvdF9qc29uGAMgASgJEhEKCWl0ZW1fanNvbhgEIAEoCRISCgplcnJvcl9jb2RlGAUgASgJEhAKCHJldmlzaW9uGAYgASgEIlIKDkNoYXRRdWV1ZUV2ZW50EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIVCg1zbmFwc2hvdF9qc29uGAIgASgJEhAKCHJldmlzaW9uGAMgASgEIoUCCglDaGF0RXZlbnQSOwoEdHlwZRgBIAEoDjItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRFdmVudC5DaGF0RXZlbnRUeXBlEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRIMCgRkYXRhGAMgASgJIpMBCg1DaGF0RXZlbnRUeXBlEgkKBVRPS0VOEAASDAoIVEhJTktJTkcQARINCglUT09MX0NBTEwQAhIPCgtUT09MX1JFU1VMVBADEggKBERPTkUQBBIJCgVFUlJPUhAFEg8KC1RPT0xfU1RBVFVTEAYSEQoNSE9TVEVEX1NFQVJDSBAHEhAKDFVTRVJfTUVTU0FHRRAIIrwBChBDaGF0Q29udHJvbEV2ZW50EhIKCnJlcXVlc3RfaWQYASABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYAiABKAkSFwoPY29udmVyc2F0aW9uX2lkGAMgASgJEhEKCXJ1bl9lcG9jaBgEIAEoAxIMCgR0eXBlGAUgASgJEg0KBXN0YXRlGAYgASgJEhIKCmVycm9yX2NvZGUYByABKAkSDwoHbWVzc2FnZRgIIAEoCRILCgNzZXEYCSABKAMi/AEKE0NoYXRSdW50aW1lU25hcHNob3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg4KBnJ1bl9pZBgCIAEoCRIZChFjbGllbnRfcmVxdWVzdF9pZBgDIAEoCRIRCgl3b3JrZXJfaWQYBCABKAkSDQoFc3RhdGUYBSABKAkSCwoDY3dkGAYgASgJEhIKCnVwZGF0ZWRfYXQYByABKAMSEAoIcmV2aXNpb24YCCABKAMSFAoMZW50cmllc19qc29uGAkgASgJEhMKC3Rvb2xfc3RhdHVzGAogASgJEiEKGXRvb2xfc3RhdHVzX2lzX2NvbXBhY3Rpb24YCyABKAgi6gEKElJ1bnRpbWVTdGF0dXNFdmVudBIRCgl3b3JrZXJfaWQYASABKAkSDQoFc3RhdGUYAiABKAkSDwoHdmlzaWJsZRgDIAEoCBIYChBhY3RpdmVfcnVuX2NvdW50GAQgASgNEhEKCXRpbWVzdGFtcBgFIAEoAxI4CgthY3RpdmVfcnVucxgGIAMoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW5SZXBvcnQSOgoNZmluaXNoZWRfcnVucxgHIAMoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW5SZXBvcnQigAEKDUNoYXRSdW5SZXBvcnQSDgoGcnVuX2lkGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRINCgVzdGF0ZRgDIAEoCRISCgplcnJvcl9jb2RlGAQgASgJEg8KB21lc3NhZ2UYBSABKAkSEgoKdXBkYXRlZF9hdBgGIAEoAyJHChFDcm9uTWFuYWdlUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSDwoHdGFza19pZBgCIAEoCRIRCgl0YXNrX2pzb24YAyABKAkiOQoSQ3Jvbk1hbmFnZVJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRITCgtyZXN1bHRfanNvbhgCIAEoCSJVChJIaXN0b3J5TGlzdFJlcXVlc3QSDAoEcGFnZRgBIAEoBRIRCglwYWdlX3NpemUYAiABKAUSCwoDY3dkGAMgASgJEhEKCWN3ZF9lbXB0eRgEIAEoCCJsChNIaXN0b3J5TGlzdFJlc3BvbnNlEkAKDWNvbnZlcnNhdGlvbnMYASADKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5EhMKC3RvdGFsX2NvdW50GAIgASgFIooCChNDb252ZXJzYXRpb25TdW1tYXJ5EgoKAmlkGAEgASgJEg0KBXRpdGxlGAIgASgJEhIKCmNyZWF0ZWRfYXQYAyABKAMSEgoKdXBkYXRlZF9hdBgEIAEoAxIVCg1tZXNzYWdlX2NvdW50GAUgASgFEhMKC3Byb3ZpZGVyX2lkGAYgASgJEg0KBW1vZGVsGAcgASgJEhIKCnNlc3Npb25faWQYCCABKAkSCwoDY3dkGAkgASgJEhEKCWlzX3Bpbm5lZBgKIAEoCBIRCglwaW5uZWRfYXQYCyABKAMSEQoJaXNfc2hhcmVkGAwgASgIEhsKE3NlbGVjdGVkX21vZGVsX2pzb24YDSABKAkiQgoRSGlzdG9yeUdldFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhQKDG1heF9tZXNzYWdlcxgCIAEoBSLUAQoSSGlzdG9yeUdldFJlc3BvbnNlEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIVCg1tZXNzYWdlc19qc29uGAIgASgJEhsKE3RvdGFsX21lc3NhZ2VfY291bnQYAyABKAUSHgoWcmV0dXJuZWRfbWVzc2FnZV9jb3VudBgEIAEoBRIQCghoYXNfbW9yZRgFIAEoCBI/Cgxjb252ZXJzYXRpb24YBiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5IoUBChRIaXN0b3J5UHJlZml4UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFAoMbWF4X21lc3NhZ2VzGAIgASgFEj4KEGJhc2VfbWVzc2FnZV9yZWYYAyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0TWVzc2FnZVJlZiLXAQoVSGlzdG9yeVByZWZpeFJlc3BvbnNlEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIVCg1tZXNzYWdlc19qc29uGAIgASgJEhsKE3RvdGFsX21lc3NhZ2VfY291bnQYAyABKAUSHgoWcmV0dXJuZWRfbWVzc2FnZV9jb3VudBgEIAEoBRIQCghoYXNfbW9yZRgFIAEoCBI/Cgxjb252ZXJzYXRpb24YBiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5Ij4KFEhpc3RvcnlSZW5hbWVSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRINCgV0aXRsZRgCIAEoCSJYChVIaXN0b3J5UmVuYW1lUmVzcG9uc2USPwoMY29udmVyc2F0aW9uGAEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeSJvChRIaXN0b3J5QnJhbmNoUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSPgoQYmFzZV9tZXNzYWdlX3JlZhgCIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRNZXNzYWdlUmVmIlgKFUhpc3RvcnlCcmFuY2hSZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5Ij8KEUhpc3RvcnlQaW5SZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIRCglpc19waW5uZWQYAiABKAgiVQoSSGlzdG9yeVBpblJlc3BvbnNlEj8KDGNvbnZlcnNhdGlvbhgBIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkikgEKEkhpc3RvcnlTaGFyZVN0YXR1cxIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHZW5hYmxlZBgCIAEoCBINCgV0b2tlbhgDIAEoCRISCgpjcmVhdGVkX2F0GAQgASgDEhIKCnVwZGF0ZWRfYXQYBSABKAMSGwoTcmVkYWN0X3Rvb2xfY29udGVudBgGIAEoCCIxChZIaXN0b3J5U2hhcmVHZXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSJSChdIaXN0b3J5U2hhcmVHZXRSZXNwb25zZRI3CgVzaGFyZRgBIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVN0YXR1cyJ8ChZIaXN0b3J5U2hhcmVTZXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIPCgdlbmFibGVkGAIgASgIEiAKE3JlZGFjdF90b29sX2NvbnRlbnQYAyABKAhIAIgBAUIWChRfcmVkYWN0X3Rvb2xfY29udGVudCJSChdIaXN0b3J5U2hhcmVTZXRSZXNwb25zZRI3CgVzaGFyZRgBIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVN0YXR1cyIrChpIaXN0b3J5U2hhcmVSZXNvbHZlUmVxdWVzdBINCgV0b2tlbhgBIAEoCSLIAQobSGlzdG9yeVNoYXJlUmVzb2x2ZVJlc3BvbnNlEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIVCg1tZXNzYWdlc19qc29uGAIgASgJEhsKE3RvdGFsX21lc3NhZ2VfY291bnQYAyABKAUSPwoMY29udmVyc2F0aW9uGAQgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeRIbChNyZWRhY3RfdG9vbF9jb250ZW50GAUgASgIIhgKFkhpc3RvcnlXb3JrZGlyc1JlcXVlc3QiVQoVSGlzdG9yeVdvcmtkaXJTdW1tYXJ5EgwKBHBhdGgYASABKAkSGgoSY29udmVyc2F0aW9uX2NvdW50GAIgASgFEhIKCnVwZGF0ZWRfYXQYAyABKAMiWAoXSGlzdG9yeVdvcmtkaXJzUmVzcG9uc2USPQoId29ya2RpcnMYASADKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5V29ya2RpclN1bW1hcnkiLwoUSGlzdG9yeURlbGV0ZVJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIhcKFUhpc3RvcnlEZWxldGVSZXNwb25zZSJ6ChBIaXN0b3J5U3luY0V2ZW50EgwKBGtpbmQYASABKAkSPwoMY29udmVyc2F0aW9uGAIgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeRIXCg9jb252ZXJzYXRpb25faWQYAyABKAkiFQoTUHJvdmlkZXJMaXN0UmVxdWVzdCIuChRQcm92aWRlckxpc3RSZXNwb25zZRIWCg5wcm92aWRlcnNfanNvbhgBIAEoCSIUChJTZXR0aW5nc0dldFJlcXVlc3QiLAoTU2V0dGluZ3NHZXRSZXNwb25zZRIVCg1zZXR0aW5nc19qc29uGAEgASgJIi4KFVNldHRpbmdzVXBkYXRlUmVxdWVzdBIVCg1zZXR0aW5nc19qc29uGAEgASgJIjsKFlNldHRpbmdzVXBkYXRlUmVzcG9uc2USEAoIYWNjZXB0ZWQYASABKAgSDwoHbWVzc2FnZRgCIAEoCSI+CiBTZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVxdWVzdBIMCgRob3N0GAEgASgJEgwKBHBvcnQYAiABKA0iNAohU2V0dGluZ3NSZXNldFNzaEtub3duSG9zdFJlc3BvbnNlEg8KB2RlbGV0ZWQYASABKA0iKgoRU2V0dGluZ3NTeW5jRXZlbnQSFQoNc2V0dGluZ3NfanNvbhgBIAEoCSIXChVTa2lsbEZpbGVzTGlzdFJlcXVlc3QiTAoWU2tpbGxGaWxlc0xpc3RSZXNwb25zZRIQCghyb290X2RpchgBIAEoCRINCgVwYXRocxgCIAMoCRIRCgl0cnVuY2F0ZWQYAyABKAgiKAoYU2tpbGxNZXRhZGF0YVJlYWRSZXF1ZXN0EgwKBHBhdGgYASABKAkiPgoZU2tpbGxNZXRhZGF0YVJlYWRSZXNwb25zZRIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJIkQKFFNraWxsVGV4dFJlYWRSZXF1ZXN0EgwKBHBhdGgYASABKAkSDgoGb2Zmc2V0GAIgASgNEg4KBmxlbmd0aBgDIAEoDSI7ChVTa2lsbFRleHRSZWFkUmVzcG9uc2USDwoHY29udGVudBgBIAEoCRIRCgl0cnVuY2F0ZWQYAiABKAgiKgoSU2tpbGxNYW5hZ2VSZXF1ZXN0EhQKDHBheWxvYWRfanNvbhgBIAEoCSIqChNTa2lsbE1hbmFnZVJlc3BvbnNlEhMKC3Jlc3VsdF9qc29uGAEgASgJIncKFkZpbGVNZW50aW9uTGlzdFJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRITCgttYXhfcmVzdWx0cxgCIAEoDRINCgVxdWVyeRgDIAEoCRIYCgtzaG93X2hpZGRlbhgEIAEoCEgAiAEBQg4KDF9zaG93X2hpZGRlbiI+ChBGaWxlTWVudGlvbkVudHJ5EgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCRIOCgZoaWRkZW4YAyABKAgiZQoXRmlsZU1lbnRpb25MaXN0UmVzcG9uc2USNwoHZW50cmllcxgBIAMoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZpbGVNZW50aW9uRW50cnkSEQoJdHJ1bmNhdGVkGAIgASgIIj8KBkZzUm9vdBIKCgJpZBgBIAEoCRIMCgRwYXRoGAIgASgJEgwKBGtpbmQYAyABKAkSDQoFbGFiZWwYBCABKAkiEAoORnNSb290c1JlcXVlc3QiPgoPRnNSb290c1Jlc3BvbnNlEisKBXJvb3RzGAEgAygLMhwubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNSb290IjYKEUZzTGlzdERpcnNSZXF1ZXN0EgwKBHBhdGgYASABKAkSEwoLbWF4X3Jlc3VsdHMYAiABKA0iKAoKRnNEaXJFbnRyeRIMCgRwYXRoGAEgASgJEgwKBG5hbWUYAiABKAkiaAoSRnNMaXN0RGlyc1Jlc3BvbnNlEgwKBHBhdGgYASABKAkSMQoHZW50cmllcxgCIAMoCzIgLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzRGlyRW50cnkSEQoJdHJ1bmNhdGVkGAMgASgIIjwKHEZzQ3JlYXRlUHJvamVjdEZvbGRlclJlcXVlc3QSDgoGcGFyZW50GAEgASgJEgwKBG5hbWUYAiABKAkiLQodRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVzcG9uc2USDAoEcGF0aBgBIAEoCSKMAQoNRnNMaXN0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkSDQoFZGVwdGgYAyABKA0SDgoGb2Zmc2V0GAQgASgNEhMKC21heF9yZXN1bHRzGAUgASgNEhgKC3Nob3dfaGlkZGVuGAYgASgISACIAQFCDgoMX3Nob3dfaGlkZGVuIjkKC0ZzTGlzdEVudHJ5EgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCRIOCgZoaWRkZW4YAyABKAgiuQEKDkZzTGlzdFJlc3BvbnNlEgwKBHBhdGgYASABKAkSEAoIaGFzX3BhdGgYAiABKAgSDQoFZGVwdGgYAyABKA0SDgoGb2Zmc2V0GAQgASgNEhMKC21heF9yZXN1bHRzGAUgASgNEg0KBXRvdGFsGAYgASgNEhAKCGhhc19tb3JlGAcgASgIEjIKB2VudHJpZXMYCCADKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0xpc3RFbnRyeSI6ChlGc1JlYWRFZGl0YWJsZVRleHRSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSKMAQoaRnNSZWFkRWRpdGFibGVUZXh0UmVzcG9uc2USDAoEcGF0aBgBIAEoCRIPCgdjb250ZW50GAIgASgJEhAKCG10aW1lX21zGAMgASgEEhQKDGNvbnRlbnRfaGFzaBgEIAEoCRISCgpzaXplX2J5dGVzGAUgASgEEhMKC3RvdGFsX2xpbmVzGAYgASgEIjwKG0ZzUmVhZFdvcmtzcGFjZUltYWdlUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkiiQEKHEZzUmVhZFdvcmtzcGFjZUltYWdlUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIRCgltaW1lX3R5cGUYAiABKAkSDAoEZGF0YRgDIAEoCRISCgpzaXplX2J5dGVzGAQgASgEEhAKCG10aW1lX21zGAUgASgEEhQKDGNvbnRlbnRfaGFzaBgGIAEoCSLbAQoTQ2hhdEZpbGVPcGVuUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHd29ya2RpchgCIAEoCRIMCgRwYXRoGAMgASgJEg4KBnNvdXJjZRgEIAEoCRIRCgRsaW5lGAUgASgNSACIAQESFQoIZW5kX2xpbmUYBiABKA1IAYgBARITCgZjb2x1bW4YByABKA1IAogBARIcChRvcGVuX2luX2ZpbGVfbWFuYWdlchgIIAEoCEIHCgVfbGluZUILCglfZW5kX2xpbmVCCQoHX2NvbHVtbiLOAQoUQ2hhdEZpbGVPcGVuUmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEgwKBGtpbmQYAiABKAkSDwoHd29ya2RpchgDIAEoCRIMCgRwYXRoGAQgASgJEhEKBGxpbmUYBSABKA1IAIgBARIVCghlbmRfbGluZRgGIAEoDUgBiAEBEhMKBmNvbHVtbhgHIAEoDUgCiAEBEhkKEW91dHNpZGVfd29ya3NwYWNlGAggASgIQgcKBV9saW5lQgsKCV9lbmRfbGluZUIJCgdfY29sdW1uIs4BChJGc1dyaXRlVGV4dFJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJEg8KB2NvbnRlbnQYAyABKAkSDAoEbW9kZRgEIAEoCRIZChFleHBlY3RlZF9tdGltZV9tcxgFIAEoBBIdChVleHBlY3RlZF9jb250ZW50X2hhc2gYBiABKAkSHQoVaGFzX2V4cGVjdGVkX210aW1lX21zGAcgASgIEiEKGWhhc19leHBlY3RlZF9jb250ZW50X2hhc2gYCCABKAginQEKE0ZzV3JpdGVUZXh0UmVzcG9uc2USDAoEcGF0aBgBIAEoCRIMCgRtb2RlGAIgASgJEhYKDmV4aXN0ZWRfYmVmb3JlGAMgASgIEhUKDWJ5dGVzX3dyaXR0ZW4YBCABKAQSEAoIbXRpbWVfbXMYBSABKAQSFAoMY29udGVudF9oYXNoGAYgASgJEhMKC3RvdGFsX2xpbmVzGAcgASgEIjMKEkZzQ3JlYXRlRGlyUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkiMQoTRnNDcmVhdGVEaXJSZXNwb25zZRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkiRgoPRnNSZW5hbWVSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSEQoJZnJvbV9wYXRoGAIgASgJEg8KB3RvX3BhdGgYAyABKAkiQQoQRnNSZW5hbWVSZXNwb25zZRIRCglmcm9tX3BhdGgYASABKAkSDAoEcGF0aBgCIAEoCRIMCgRraW5kGAMgASgJIjAKD0ZzRGVsZXRlUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkiLgoQRnNEZWxldGVSZXNwb25zZRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkiIAoLUGluZ1JlcXVlc3QSEQoJdGltZXN0YW1wGAEgASgDIiEKDFBvbmdSZXNwb25zZRIRCgl0aW1lc3RhbXAYASABKAMiLgoNRXJyb3JSZXNwb25zZRIMCgRjb2RlGAEgASgFEg8KB21lc3NhZ2UYAiABKAkifwoVUHJvdmlkZXJNb2RlbHNSZXF1ZXN0EhUKDXByb3ZpZGVyX3R5cGUYASABKAkSEAoIYmFzZV91cmwYAiABKAkSDwoHYXBpX2tleRgDIAEoCRIYChB1c2Vfc3lzdGVtX3Byb3h5GAQgASgIEhIKCm1vZGVsc191cmwYBSABKAkiLQoWUHJvdmlkZXJNb2RlbHNSZXNwb25zZRITCgttb2RlbHNfanNvbhgBIAEoCSJRChRQcm92aWRlclVzYWdlUmVxdWVzdBITCgtwcm92aWRlcl9pZBgBIAEoCRIPCgdyZWZyZXNoGAIgASgIEhMKC2NvbmZpZ19qc29uGAMgASgJIiwKFVByb3ZpZGVyVXNhZ2VSZXNwb25zZRITCgtyZXN1bHRfanNvbhgBIAEoCSKIAQoQQ2hhdEluZ3Jlc3NCYXRjaBIOCgZydW5faWQYASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEhEKCWZpcnN0X3NlcRgDIAEoBBI4CgdyZWNvcmRzGAQgAygLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NSZWNvcmQimgIKEUNoYXRJbmdyZXNzUmVjb3JkEjcKBWRlbHRhGAEgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NEZWx0YUgAEkEKCmNoZWNrcG9pbnQYAiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0NoZWNrcG9pbnRIABI9Cgh0ZXJtaW5hbBgDIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzVGVybWluYWxIABI/CgloZWFydGJlYXQYBCABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0hlYXJ0YmVhdEgAQgkKB3BheWxvYWQiOQoQQ2hhdEluZ3Jlc3NEZWx0YRISCgpldmVudF9qc29uGAEgASgJEhEKCXdvcmtlcl9pZBgCIAEoCSIqChRDaGF0SW5ncmVzc0hlYXJ0YmVhdBISCgp1cGRhdGVkX2F0GAEgASgDIsQBChVDaGF0SW5ncmVzc0NoZWNrcG9pbnQSGgoSY292ZXJzX3Rocm91Z2hfc2VxGAEgASgEEhAKCHJldmlzaW9uGAIgASgEEh0KFWNvbXByZXNzZWRfcHJvamVjdGlvbhgDIAEoDBIaChJ1bmNvbXByZXNzZWRfYnl0ZXMYBCABKAQSDgoGc2hhMjU2GAUgASgJEhgKEGNvbnRlbnRfY29tcGxldGUYBiABKAgSGAoQaGlzdG9yeV9yZXF1aXJlZBgHIAEoCCL8AQoTQ2hhdEluZ3Jlc3NUZXJtaW5hbBIaChJjb3ZlcnNfdGhyb3VnaF9zZXEYASABKAQSEAoIcmV2aXNpb24YAiABKAQSHQoVY29tcHJlc3NlZF9wcm9qZWN0aW9uGAMgASgMEhoKEnVuY29tcHJlc3NlZF9ieXRlcxgEIAEoBBIOCgZzaGEyNTYYBSABKAkSGAoQY29udGVudF9jb21wbGV0ZRgGIAEoCBIYChBoaXN0b3J5X3JlcXVpcmVkGAcgASgIEg0KBXN0YXRlGAggASgJEhIKCmVycm9yX2NvZGUYCSABKAkSFQoNZXJyb3JfbWVzc2FnZRgKIAEoCSJNChFDaGF0SW5ncmVzc1Jlc3VtZRI4CgRydW5zGAEgAygLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NSdW5SZXN1bWUi1QEKFENoYXRJbmdyZXNzUnVuUmVzdW1lEg4KBnJ1bl9pZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSFwoPcmVwbGF5X2Zyb21fc2VxGAMgASgEEhoKEnJlcGxheV90aHJvdWdoX3NlcRgEIAEoBBIQCghuZXh0X3NlcRgFIAEoBBIdChVsYXRlc3RfY2hlY2twb2ludF9zZXEYBiABKAQSFAoMdGVybWluYWxfc2VxGAcgASgEEhgKEHRlcm1pbmFsX3BlbmRpbmcYCCABKAgizgEKE0NoYXRJbmdyZXNzRnJhZ21lbnQSDgoGcnVuX2lkGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRISCgpzb3VyY2Vfc2VxGAMgASgEEhYKDmZyYWdtZW50X2luZGV4GAQgASgNEhYKDmZyYWdtZW50X2NvdW50GAUgASgNEhwKFGVuY29kZWRfcmVjb3JkX2NodW5rGAYgASgMEhwKFGVuY29kZWRfcmVjb3JkX2J5dGVzGAcgASgEEg4KBnNoYTI1NhgIIAEoCSLcAgoOQ2hhdEluZ3Jlc3NBY2sSDgoGcnVuX2lkGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRIZChFjb21taXR0ZWRfdGhyb3VnaBgDIAEoBBIVCg1leHBlY3RlZF9uZXh0GAQgASgEEjsKBmFjdGlvbhgFIAEoDjIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzQWNrLkFjdGlvbhIaChJ0ZXJtaW5hbF9jb21taXR0ZWQYBiABKAgSEgoKZXJyb3JfY29kZRgHIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAggASgJImsKBkFjdGlvbhIWChJBQ1RJT05fVU5TUEVDSUZJRUQQABIMCghDT05USU5VRRABEhgKFFJFUExBWV9GUk9NX0VYUEVDVEVEEAISEwoPU0VORF9DSEVDS1BPSU5UEAMSDAoIUkVKRUNURUQQBCI8ChRIaXN0b3J5U2V0Q3dkUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSCwoDY3dkGAIgASgJIlgKFUhpc3RvcnlTZXRDd2RSZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5KsYECg9UdW5uZWxGcmFtZUtpbmQSIQodVFVOTkVMX0ZSQU1FX0tJTkRfVU5TUEVDSUZJRUQQABIoCiRUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFUVVFU1RfU1RBUlQQARInCiNUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFUVVFU1RfQk9EWRACEiYKIlRVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVRVUVTVF9FTkQQAxIpCiVUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFU1BPTlNFX1NUQVJUEAQSKAokVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVNQT05TRV9CT0RZEAUSJwojVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVNQT05TRV9FTkQQBhIdChlUVU5ORUxfRlJBTUVfS0lORF9XU19ESUFMEAcSIAocVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRElBTF9PSxAIEiMKH1RVTk5FTF9GUkFNRV9LSU5EX1dTX0RJQUxfRVJST1IQCRIeChpUVU5ORUxfRlJBTUVfS0lORF9XU19GUkFNRRAKEh4KGlRVTk5FTF9GUkFNRV9LSU5EX1dTX0NMT1NFEAsSGwoXVFVOTkVMX0ZSQU1FX0tJTkRfRVJST1IQDBIcChhUVU5ORUxfRlJBTUVfS0lORF9DQU5DRUwQDRIaChZUVU5ORUxfRlJBTUVfS0lORF9QSU5HEA4SGgoWVFVOTkVMX0ZSQU1FX0tJTkRfUE9ORxAPKoEBChNUdW5uZWxXc01lc3NhZ2VUeXBlEiYKIlRVTk5FTF9XU19NRVNTQUdFX1RZUEVfVU5TUEVDSUZJRUQQABIfChtUVU5ORUxfV1NfTUVTU0FHRV9UWVBFX1RFWFQQARIhCh1UVU5ORUxfV1NfTUVTU0FHRV9UWVBFX0JJTkFSWRACQkBaPmdpdGh1Yi5jb20vbGl2ZWFnZW50L2FnZW50LWdhdGV3YXkvaW50ZXJuYWwvcHJvdG8vdjI7Z2F0ZXdheXYyYgZwcm90bzM"); + +/** + * @generated from message liveagent.gateway.v2.GatewayEnvelope + */ +export type GatewayEnvelope = Message<"liveagent.gateway.v2.GatewayEnvelope"> & { + /** + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from field: int64 timestamp = 2; + */ + timestamp: bigint; + + /** + * @generated from oneof liveagent.gateway.v2.GatewayEnvelope.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ChatCommandRequest chat_command = 10; + */ + value: ChatCommandRequest; + case: "chatCommand"; + } | { + /** + * @generated from field: liveagent.gateway.v2.CronManageRequest cron_manage = 20; + */ + value: CronManageRequest; + case: "cronManage"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryListRequest history_list = 30; + */ + value: HistoryListRequest; + case: "historyList"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryGetRequest history_get = 31; + */ + value: HistoryGetRequest; + case: "historyGet"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryRenameRequest history_rename = 32; + */ + value: HistoryRenameRequest; + case: "historyRename"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryDeleteRequest history_delete = 33; + */ + value: HistoryDeleteRequest; + case: "historyDelete"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryPrefixRequest history_prefix = 34; + */ + value: HistoryPrefixRequest; + case: "historyPrefix"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryPinRequest history_pin = 35; + */ + value: HistoryPinRequest; + case: "historyPin"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareGetRequest history_share_get = 36; + */ + value: HistoryShareGetRequest; + case: "historyShareGet"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareSetRequest history_share_set = 37; + */ + value: HistoryShareSetRequest; + case: "historyShareSet"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareResolveRequest history_share_resolve = 38; + */ + value: HistoryShareResolveRequest; + case: "historyShareResolve"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryWorkdirsRequest history_workdirs = 39; + */ + value: HistoryWorkdirsRequest; + case: "historyWorkdirs"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ProviderListRequest provider_list = 40; + */ + value: ProviderListRequest; + case: "providerList"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsGetRequest settings_get = 41; + */ + value: SettingsGetRequest; + case: "settingsGet"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsUpdateRequest settings_update = 42; + */ + value: SettingsUpdateRequest; + case: "settingsUpdate"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillFilesListRequest skill_files_list = 43; + */ + value: SkillFilesListRequest; + case: "skillFilesList"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillMetadataReadRequest skill_metadata_read = 44; + */ + value: SkillMetadataReadRequest; + case: "skillMetadataRead"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillTextReadRequest skill_text_read = 45; + */ + value: SkillTextReadRequest; + case: "skillTextRead"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FileMentionListRequest file_mention_list = 46; + */ + value: FileMentionListRequest; + case: "fileMentionList"; + } | { + /** + * @generated from field: liveagent.gateway.v2.UploadReadableFilesRequest upload_readable_files = 47; + */ + value: UploadReadableFilesRequest; + case: "uploadReadableFiles"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsRootsRequest fs_roots = 48; + */ + value: FsRootsRequest; + case: "fsRoots"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsListDirsRequest fs_list_dirs = 49; + */ + value: FsListDirsRequest; + case: "fsListDirs"; + } | { + /** + * @generated from field: liveagent.gateway.v2.PingRequest ping = 50; + */ + value: PingRequest; + case: "ping"; + } | { + /** + * @generated from field: liveagent.gateway.v2.UploadedImagePreviewRequest uploaded_image_preview = 51; + */ + value: UploadedImagePreviewRequest; + case: "uploadedImagePreview"; + } | { + /** + * @generated from field: liveagent.gateway.v2.MemoryManageRequest memory_manage = 52; + */ + value: MemoryManageRequest; + case: "memoryManage"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillManageRequest skill_manage = 53; + */ + value: SkillManageRequest; + case: "skillManage"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsCreateProjectFolderRequest fs_create_project_folder = 54; + */ + value: FsCreateProjectFolderRequest; + case: "fsCreateProjectFolder"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TerminalRequest terminal_request = 55; + */ + value: TerminalRequest; + case: "terminalRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsListRequest fs_list = 56; + */ + value: FsListRequest; + case: "fsList"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsWriteTextRequest fs_write_text = 57; + */ + value: FsWriteTextRequest; + case: "fsWriteText"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsCreateDirRequest fs_create_dir = 58; + */ + value: FsCreateDirRequest; + case: "fsCreateDir"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsRenameRequest fs_rename = 59; + */ + value: FsRenameRequest; + case: "fsRename"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsDeleteRequest fs_delete = 60; + */ + value: FsDeleteRequest; + case: "fsDelete"; + } | { + /** + * @generated from field: liveagent.gateway.v2.GitRequest git_request = 61; + */ + value: GitRequest; + case: "gitRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsReadEditableTextRequest fs_read_editable_text = 62; + */ + value: FsReadEditableTextRequest; + case: "fsReadEditableText"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsReadWorkspaceImageRequest fs_read_workspace_image = 63; + */ + value: FsReadWorkspaceImageRequest; + case: "fsReadWorkspaceImage"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SftpRequest sftp_request = 64; + */ + value: SftpRequest; + case: "sftpRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ProviderModelsRequest provider_models = 65; + */ + value: ProviderModelsRequest; + case: "providerModels"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsResetSshKnownHostRequest settings_reset_ssh_known_host = 72; + */ + value: SettingsResetSshKnownHostRequest; + case: "settingsResetSshKnownHost"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatQueueRequest chat_queue = 73; + */ + value: ChatQueueRequest; + case: "chatQueue"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressAck chat_ingress_ack = 75; + */ + value: ChatIngressAck; + case: "chatIngressAck"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelStateSnapshot tunnel_state = 80; + */ + value: TunnelStateSnapshot; + case: "tunnelState"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelMutation tunnel_mutation = 81; + */ + value: TunnelMutation; + case: "tunnelMutation"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelFrame tunnel_frame = 82; + */ + value: TunnelFrame; + case: "tunnelFrame"; + } | { + /** + * @generated from field: liveagent.gateway.v2.WorkspaceWatchRequest workspace_watch = 90; + */ + value: WorkspaceWatchRequest; + case: "workspaceWatch"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ManagedProcessRequest managed_process_request = 91; + */ + value: ManagedProcessRequest; + case: "managedProcessRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryBranchRequest history_branch = 92; + */ + value: HistoryBranchRequest; + case: "historyBranch"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ProviderUsageRequest provider_usage = 93; + */ + value: ProviderUsageRequest; + case: "providerUsage"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatFileOpenRequest chat_file_open = 94; + */ + value: ChatFileOpenRequest; + case: "chatFileOpen"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistorySetCwdRequest history_set_cwd = 95; + */ + value: HistorySetCwdRequest; + case: "historySetCwd"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.GatewayEnvelope. + * Use `create(GatewayEnvelopeSchema)` to create a new message. + */ +export const GatewayEnvelopeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 0); + +/** + * @generated from message liveagent.gateway.v2.AgentEnvelope + */ +export type AgentEnvelope = Message<"liveagent.gateway.v2.AgentEnvelope"> & { + /** + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from field: int64 timestamp = 2; + */ + timestamp: bigint; + + /** + * @generated from oneof liveagent.gateway.v2.AgentEnvelope.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ChatEvent chat_event = 10; + */ + value: ChatEvent; + case: "chatEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.CronManageResponse cron_manage_resp = 20; + */ + value: CronManageResponse; + case: "cronManageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryListResponse history_list_resp = 30; + */ + value: HistoryListResponse; + case: "historyListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryGetResponse history_get_resp = 31; + */ + value: HistoryGetResponse; + case: "historyGetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryRenameResponse history_rename_resp = 32; + */ + value: HistoryRenameResponse; + case: "historyRenameResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryDeleteResponse history_delete_resp = 33; + */ + value: HistoryDeleteResponse; + case: "historyDeleteResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistorySyncEvent history_sync = 34; + */ + value: HistorySyncEvent; + case: "historySync"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryPrefixResponse history_prefix_resp = 35; + */ + value: HistoryPrefixResponse; + case: "historyPrefixResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryPinResponse history_pin_resp = 36; + */ + value: HistoryPinResponse; + case: "historyPinResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareGetResponse history_share_get_resp = 37; + */ + value: HistoryShareGetResponse; + case: "historyShareGetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareSetResponse history_share_set_resp = 38; + */ + value: HistoryShareSetResponse; + case: "historyShareSetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareResolveResponse history_share_resolve_resp = 39; + */ + value: HistoryShareResolveResponse; + case: "historyShareResolveResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryWorkdirsResponse history_workdirs_resp = 56; + */ + value: HistoryWorkdirsResponse; + case: "historyWorkdirsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ProviderListResponse provider_list_resp = 40; + */ + value: ProviderListResponse; + case: "providerListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsGetResponse settings_get_resp = 41; + */ + value: SettingsGetResponse; + case: "settingsGetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsUpdateResponse settings_update_resp = 42; + */ + value: SettingsUpdateResponse; + case: "settingsUpdateResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsSyncEvent settings_sync = 43; + */ + value: SettingsSyncEvent; + case: "settingsSync"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillFilesListResponse skill_files_list_resp = 44; + */ + value: SkillFilesListResponse; + case: "skillFilesListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillMetadataReadResponse skill_metadata_read_resp = 45; + */ + value: SkillMetadataReadResponse; + case: "skillMetadataReadResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillTextReadResponse skill_text_read_resp = 46; + */ + value: SkillTextReadResponse; + case: "skillTextReadResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FileMentionListResponse file_mention_list_resp = 47; + */ + value: FileMentionListResponse; + case: "fileMentionListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.UploadReadableFilesResponse upload_readable_files_resp = 48; + */ + value: UploadReadableFilesResponse; + case: "uploadReadableFilesResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsRootsResponse fs_roots_resp = 49; + */ + value: FsRootsResponse; + case: "fsRootsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.PongResponse pong = 50; + */ + value: PongResponse; + case: "pong"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsListDirsResponse fs_list_dirs_resp = 51; + */ + value: FsListDirsResponse; + case: "fsListDirsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.UploadedImagePreviewResponse uploaded_image_preview_resp = 52; + */ + value: UploadedImagePreviewResponse; + case: "uploadedImagePreviewResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.MemoryManageResponse memory_manage_resp = 53; + */ + value: MemoryManageResponse; + case: "memoryManageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SkillManageResponse skill_manage_resp = 54; + */ + value: SkillManageResponse; + case: "skillManageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsCreateProjectFolderResponse fs_create_project_folder_resp = 55; + */ + value: FsCreateProjectFolderResponse; + case: "fsCreateProjectFolderResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TerminalResponse terminal_response = 57; + */ + value: TerminalResponse; + case: "terminalResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TerminalEvent terminal_event = 58; + */ + value: TerminalEvent; + case: "terminalEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsListResponse fs_list_resp = 59; + */ + value: FsListResponse; + case: "fsListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsWriteTextResponse fs_write_text_resp = 60; + */ + value: FsWriteTextResponse; + case: "fsWriteTextResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsCreateDirResponse fs_create_dir_resp = 61; + */ + value: FsCreateDirResponse; + case: "fsCreateDirResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsRenameResponse fs_rename_resp = 62; + */ + value: FsRenameResponse; + case: "fsRenameResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsDeleteResponse fs_delete_resp = 63; + */ + value: FsDeleteResponse; + case: "fsDeleteResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.GitResponse git_response = 64; + */ + value: GitResponse; + case: "gitResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsReadEditableTextResponse fs_read_editable_text_resp = 65; + */ + value: FsReadEditableTextResponse; + case: "fsReadEditableTextResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.FsReadWorkspaceImageResponse fs_read_workspace_image_resp = 66; + */ + value: FsReadWorkspaceImageResponse; + case: "fsReadWorkspaceImageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SftpResponse sftp_response = 73; + */ + value: SftpResponse; + case: "sftpResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SftpEvent sftp_event = 74; + */ + value: SftpEvent; + case: "sftpEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatQueueResponse chat_queue_resp = 75; + */ + value: ChatQueueResponse; + case: "chatQueueResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatQueueEvent chat_queue_event = 76; + */ + value: ChatQueueEvent; + case: "chatQueueEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatControlEvent chat_control = 70; + */ + value: ChatControlEvent; + case: "chatControl"; + } | { + /** + * @generated from field: liveagent.gateway.v2.RuntimeStatusEvent runtime_status = 71; + */ + value: RuntimeStatusEvent; + case: "runtimeStatus"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsResetSshKnownHostResponse settings_reset_ssh_known_host_resp = 72; + */ + value: SettingsResetSshKnownHostResponse; + case: "settingsResetSshKnownHostResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatRuntimeSnapshot chat_runtime_snapshot = 77; + */ + value: ChatRuntimeSnapshot; + case: "chatRuntimeSnapshot"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ProviderModelsResponse provider_models_resp = 79; + */ + value: ProviderModelsResponse; + case: "providerModelsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelDesiredState tunnel_desired = 80; + */ + value: TunnelDesiredState; + case: "tunnelDesired"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelMutationResult tunnel_mutation_result = 81; + */ + value: TunnelMutationResult; + case: "tunnelMutationResult"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelFrame tunnel_frame = 82; + */ + value: TunnelFrame; + case: "tunnelFrame"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelProbeReport tunnel_probe_report = 83; + */ + value: TunnelProbeReport; + case: "tunnelProbeReport"; + } | { + /** + * @generated from field: liveagent.gateway.v2.WorkspaceActivityEvent workspace_activity = 90; + */ + value: WorkspaceActivityEvent; + case: "workspaceActivity"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ManagedProcessResponse managed_process_response = 91; + */ + value: ManagedProcessResponse; + case: "managedProcessResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ManagedProcessSnapshot managed_process_snapshot = 92; + */ + value: ManagedProcessSnapshot; + case: "managedProcessSnapshot"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistoryBranchResponse history_branch_resp = 93; + */ + value: HistoryBranchResponse; + case: "historyBranchResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ProviderUsageResponse provider_usage_resp = 94; + */ + value: ProviderUsageResponse; + case: "providerUsageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressBatch chat_ingress_batch = 95; + */ + value: ChatIngressBatch; + case: "chatIngressBatch"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressResume chat_ingress_resume = 96; + */ + value: ChatIngressResume; + case: "chatIngressResume"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressFragment chat_ingress_fragment = 97; + */ + value: ChatIngressFragment; + case: "chatIngressFragment"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatFileOpenResponse chat_file_open_resp = 98; + */ + value: ChatFileOpenResponse; + case: "chatFileOpenResp"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ErrorResponse error = 99; + */ + value: ErrorResponse; + case: "error"; + } | { + /** + * @generated from field: liveagent.gateway.v2.HistorySetCwdResponse history_set_cwd_resp = 100; + */ + value: HistorySetCwdResponse; + case: "historySetCwdResp"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.AgentEnvelope. + * Use `create(AgentEnvelopeSchema)` to create a new message. + */ +export const AgentEnvelopeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 1); + +/** + * @generated from message liveagent.gateway.v2.ChatSelectedModel + */ +export type ChatSelectedModel = Message<"liveagent.gateway.v2.ChatSelectedModel"> & { + /** + * @generated from field: string custom_provider_id = 1; + */ + customProviderId: string; + + /** + * @generated from field: string model = 2; + */ + model: string; + + /** + * @generated from field: string provider_type = 3; + */ + providerType: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatSelectedModel. + * Use `create(ChatSelectedModelSchema)` to create a new message. + */ +export const ChatSelectedModelSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 2); + +/** + * @generated from message liveagent.gateway.v2.ChatRuntimeControls + */ +export type ChatRuntimeControls = Message<"liveagent.gateway.v2.ChatRuntimeControls"> & { + /** + * @generated from field: bool thinking_enabled = 1; + */ + thinkingEnabled: boolean; + + /** + * @generated from field: bool native_web_search_enabled = 2; + */ + nativeWebSearchEnabled: boolean; + + /** + * @generated from field: string reasoning = 3; + */ + reasoning: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRuntimeControls. + * Use `create(ChatRuntimeControlsSchema)` to create a new message. + */ +export const ChatRuntimeControlsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 3); + +/** + * @generated from message liveagent.gateway.v2.ChatUploadedFile + */ +export type ChatUploadedFile = Message<"liveagent.gateway.v2.ChatUploadedFile"> & { + /** + * @generated from field: string relative_path = 1; + */ + relativePath: string; + + /** + * @generated from field: string absolute_path = 2; + */ + absolutePath: string; + + /** + * @generated from field: string file_name = 3; + */ + fileName: string; + + /** + * @generated from field: string kind = 4; + */ + kind: string; + + /** + * @generated from field: int64 size_bytes = 5; + */ + sizeBytes: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatUploadedFile. + * Use `create(ChatUploadedFileSchema)` to create a new message. + */ +export const ChatUploadedFileSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 4); + +/** + * @generated from message liveagent.gateway.v2.UploadReadableFile + */ +export type UploadReadableFile = Message<"liveagent.gateway.v2.UploadReadableFile"> & { + /** + * @generated from field: string file_name = 1; + */ + fileName: string; + + /** + * @generated from field: string mime_type = 2; + */ + mimeType: string; + + /** + * @generated from field: bytes content = 3; + */ + content: Uint8Array; +}; + +/** + * Describes the message liveagent.gateway.v2.UploadReadableFile. + * Use `create(UploadReadableFileSchema)` to create a new message. + */ +export const UploadReadableFileSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 5); + +/** + * @generated from message liveagent.gateway.v2.UploadReadableFilesRequest + */ +export type UploadReadableFilesRequest = Message<"liveagent.gateway.v2.UploadReadableFilesRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: repeated liveagent.gateway.v2.UploadReadableFile files = 2; + */ + files: UploadReadableFile[]; +}; + +/** + * Describes the message liveagent.gateway.v2.UploadReadableFilesRequest. + * Use `create(UploadReadableFilesRequestSchema)` to create a new message. + */ +export const UploadReadableFilesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 6); + +/** + * @generated from message liveagent.gateway.v2.UploadReadableFilesResponse + */ +export type UploadReadableFilesResponse = Message<"liveagent.gateway.v2.UploadReadableFilesResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.ChatUploadedFile files = 1; + */ + files: ChatUploadedFile[]; + + /** + * @generated from field: repeated string skipped = 2; + */ + skipped: string[]; +}; + +/** + * Describes the message liveagent.gateway.v2.UploadReadableFilesResponse. + * Use `create(UploadReadableFilesResponseSchema)` to create a new message. + */ +export const UploadReadableFilesResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 7); + +/** + * @generated from message liveagent.gateway.v2.UploadedImagePreviewRequest + */ +export type UploadedImagePreviewRequest = Message<"liveagent.gateway.v2.UploadedImagePreviewRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string absolute_path = 2; + */ + absolutePath: string; +}; + +/** + * Describes the message liveagent.gateway.v2.UploadedImagePreviewRequest. + * Use `create(UploadedImagePreviewRequestSchema)` to create a new message. + */ +export const UploadedImagePreviewRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 8); + +/** + * @generated from message liveagent.gateway.v2.UploadedImagePreviewResponse + */ +export type UploadedImagePreviewResponse = Message<"liveagent.gateway.v2.UploadedImagePreviewResponse"> & { + /** + * @generated from field: string mime_type = 1; + */ + mimeType: string; + + /** + * @generated from field: string data = 2; + */ + data: string; +}; + +/** + * Describes the message liveagent.gateway.v2.UploadedImagePreviewResponse. + * Use `create(UploadedImagePreviewResponseSchema)` to create a new message. + */ +export const UploadedImagePreviewResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 9); + +/** + * @generated from message liveagent.gateway.v2.TunnelSpec + */ +export type TunnelSpec = Message<"liveagent.gateway.v2.TunnelSpec"> & { + /** + * agent-generated, stable across restarts + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * last allocated slug; gateway honors when valid and unused + * + * @generated from field: string slug_hint = 2; + */ + slugHint: string; + + /** + * @generated from field: string name = 3; + */ + name: string; + + /** + * http://localhost:PORT[/base] + * + * @generated from field: string target_url = 4; + */ + targetUrl: string; + + /** + * unix seconds, 0 = never + * + * @generated from field: int64 expires_at = 5; + */ + expiresAt: bigint; + + /** + * @generated from field: string project_path_key = 6; + */ + projectPathKey: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelSpec. + * Use `create(TunnelSpecSchema)` to create a new message. + */ +export const TunnelSpecSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 10); + +/** + * @generated from message liveagent.gateway.v2.TunnelDesiredState + */ +export type TunnelDesiredState = Message<"liveagent.gateway.v2.TunnelDesiredState"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.TunnelSpec tunnels = 1; + */ + tunnels: TunnelSpec[]; + + /** + * agent-side monotonic + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelDesiredState. + * Use `create(TunnelDesiredStateSchema)` to create a new message. + */ +export const TunnelDesiredStateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 11); + +/** + * @generated from message liveagent.gateway.v2.TunnelHealth + */ +export type TunnelHealth = Message<"liveagent.gateway.v2.TunnelHealth"> & { + /** + * "ok" | "failed" | "unknown" + * + * @generated from field: string status = 1; + */ + status: string; + + /** + * local layer only + * + * @generated from field: uint32 http_status = 2; + */ + httpStatus: number; + + /** + * @generated from field: string error = 3; + */ + error: string; + + /** + * @generated from field: int64 checked_at = 4; + */ + checkedAt: bigint; + + /** + * relay layer only + * + * @generated from field: uint32 rtt_ms = 5; + */ + rttMs: number; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelHealth. + * Use `create(TunnelHealthSchema)` to create a new message. + */ +export const TunnelHealthSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 12); + +/** + * @generated from message liveagent.gateway.v2.TunnelStatus + */ +export type TunnelStatus = Message<"liveagent.gateway.v2.TunnelStatus"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string slug = 2; + */ + slug: string; + + /** + * @generated from field: string name = 3; + */ + name: string; + + /** + * @generated from field: string target_url = 4; + */ + targetUrl: string; + + /** + * "/t/{slug}/"; clients compose the full URL + * + * @generated from field: string public_path = 5; + */ + publicPath: string; + + /** + * @generated from field: int64 created_at = 6; + */ + createdAt: bigint; + + /** + * @generated from field: int64 expires_at = 7; + */ + expiresAt: bigint; + + /** + * @generated from field: uint32 active_connections = 8; + */ + activeConnections: number; + + /** + * @generated from field: string project_path_key = 9; + */ + projectPathKey: string; + + /** + * agent -> local service reachability + * + * @generated from field: liveagent.gateway.v2.TunnelHealth local = 10; + */ + local?: TunnelHealth | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelStatus. + * Use `create(TunnelStatusSchema)` to create a new message. + */ +export const TunnelStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 13); + +/** + * @generated from message liveagent.gateway.v2.TunnelStateSnapshot + */ +export type TunnelStateSnapshot = Message<"liveagent.gateway.v2.TunnelStateSnapshot"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.TunnelStatus tunnels = 1; + */ + tunnels: TunnelStatus[]; + + /** + * gateway-side monotonic + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; + + /** + * @generated from field: bool agent_online = 3; + */ + agentOnline: boolean; + + /** + * gateway <-> agent frame path + * + * @generated from field: liveagent.gateway.v2.TunnelHealth relay = 4; + */ + relay?: TunnelHealth | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelStateSnapshot. + * Use `create(TunnelStateSnapshotSchema)` to create a new message. + */ +export const TunnelStateSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 14); + +/** + * @generated from message liveagent.gateway.v2.TunnelMutation + */ +export type TunnelMutation = Message<"liveagent.gateway.v2.TunnelMutation"> & { + /** + * "create" | "update" | "close" | "check" + * + * @generated from field: string action = 1; + */ + action: string; + + /** + * update/close/check + * + * @generated from field: string tunnel_id = 2; + */ + tunnelId: string; + + /** + * @generated from field: string target_url = 3; + */ + targetUrl: string; + + /** + * @generated from field: string name = 4; + */ + name: string; + + /** + * absent on update = keep current expiry + * + * @generated from field: optional uint32 ttl_seconds = 5; + */ + ttlSeconds?: number | undefined; + + /** + * @generated from field: string project_path_key = 6; + */ + projectPathKey: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelMutation. + * Use `create(TunnelMutationSchema)` to create a new message. + */ +export const TunnelMutationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 15); + +/** + * @generated from message liveagent.gateway.v2.TunnelMutationResult + */ +export type TunnelMutationResult = Message<"liveagent.gateway.v2.TunnelMutationResult"> & { + /** + * @generated from field: string tunnel_id = 1; + */ + tunnelId: string; + + /** + * "" = ok; invalid_target|limit_exceeded|not_found|invalid_ttl + * + * @generated from field: string error_code = 2; + */ + errorCode: string; + + /** + * @generated from field: string error_message = 3; + */ + errorMessage: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelMutationResult. + * Use `create(TunnelMutationResultSchema)` to create a new message. + */ +export const TunnelMutationResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 16); + +/** + * @generated from message liveagent.gateway.v2.TunnelProbeResult + */ +export type TunnelProbeResult = Message<"liveagent.gateway.v2.TunnelProbeResult"> & { + /** + * @generated from field: string tunnel_id = 1; + */ + tunnelId: string; + + /** + * @generated from field: liveagent.gateway.v2.TunnelHealth local = 2; + */ + local?: TunnelHealth | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelProbeResult. + * Use `create(TunnelProbeResultSchema)` to create a new message. + */ +export const TunnelProbeResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 17); + +/** + * @generated from message liveagent.gateway.v2.TunnelProbeReport + */ +export type TunnelProbeReport = Message<"liveagent.gateway.v2.TunnelProbeReport"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.TunnelProbeResult results = 1; + */ + results: TunnelProbeResult[]; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelProbeReport. + * Use `create(TunnelProbeReportSchema)` to create a new message. + */ +export const TunnelProbeReportSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 18); + +/** + * @generated from message liveagent.gateway.v2.TunnelHeader + */ +export type TunnelHeader = Message<"liveagent.gateway.v2.TunnelHeader"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string value = 2; + */ + value: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelHeader. + * Use `create(TunnelHeaderSchema)` to create a new message. + */ +export const TunnelHeaderSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 19); + +/** + * @generated from message liveagent.gateway.v2.TunnelFrame + */ +export type TunnelFrame = Message<"liveagent.gateway.v2.TunnelFrame"> & { + /** + * @generated from field: string stream_id = 1; + */ + streamId: string; + + /** + * @generated from field: liveagent.gateway.v2.TunnelFrameKind kind = 2; + */ + kind: TunnelFrameKind; + + /** + * set on HTTP_REQUEST_START and WS_DIAL only + * + * @generated from field: string target_url = 3; + */ + targetUrl: string; + + /** + * @generated from field: string method = 4; + */ + method: string; + + /** + * path+query relative to the target base + * + * @generated from field: string path = 5; + */ + path: string; + + /** + * @generated from field: repeated liveagent.gateway.v2.TunnelHeader headers = 6; + */ + headers: TunnelHeader[]; + + /** + * @generated from field: uint32 status = 7; + */ + status: number; + + /** + * @generated from field: bytes body = 8; + */ + body: Uint8Array; + + /** + * @generated from field: string error = 9; + */ + error: string; + + /** + * @generated from field: liveagent.gateway.v2.TunnelWsMessageType ws_message_type = 10; + */ + wsMessageType: TunnelWsMessageType; + + /** + * @generated from field: string ws_subprotocol = 11; + */ + wsSubprotocol: string; + + /** + * @generated from field: uint32 ws_close_code = 12; + */ + wsCloseCode: number; + + /** + * @generated from field: string ws_close_reason = 13; + */ + wsCloseReason: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TunnelFrame. + * Use `create(TunnelFrameSchema)` to create a new message. + */ +export const TunnelFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 20); + +/** + * @generated from message liveagent.gateway.v2.WorkspaceWatchRequest + */ +export type WorkspaceWatchRequest = Message<"liveagent.gateway.v2.WorkspaceWatchRequest"> & { + /** + * full desired set; replaces the previous one + * + * @generated from field: repeated string workdirs = 1; + */ + workdirs: string[]; +}; + +/** + * Describes the message liveagent.gateway.v2.WorkspaceWatchRequest. + * Use `create(WorkspaceWatchRequestSchema)` to create a new message. + */ +export const WorkspaceWatchRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 21); + +/** + * @generated from message liveagent.gateway.v2.WorkspaceActivityEvent + */ +export type WorkspaceActivityEvent = Message<"liveagent.gateway.v2.WorkspaceActivityEvent"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * per-workdir monotonic within one agent process + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; + + /** + * working-tree content changed + * + * @generated from field: bool fs = 3; + */ + fs: boolean; + + /** + * git state (HEAD/refs/index/...) changed + * + * @generated from field: bool git = 4; + */ + git: boolean; + + /** + * relative to workdir, deduped, capped + * + * @generated from field: repeated string changed_paths = 5; + */ + changedPaths: string[]; + + /** + * changed_paths hit the cap or is unknown + * + * @generated from field: bool truncated = 6; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.WorkspaceActivityEvent. + * Use `create(WorkspaceActivityEventSchema)` to create a new message. + */ +export const WorkspaceActivityEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 22); + +/** + * @generated from message liveagent.gateway.v2.ManagedProcessRecord + */ +export type ManagedProcessRecord = Message<"liveagent.gateway.v2.ManagedProcessRecord"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string label = 2; + */ + label: string; + + /** + * @generated from field: string command = 3; + */ + command: string; + + /** + * @generated from field: string cwd = 4; + */ + cwd: string; + + /** + * @generated from field: string shell = 5; + */ + shell: string; + + /** + * @generated from field: uint32 pid = 6; + */ + pid: number; + + /** + * @generated from field: string log_path = 7; + */ + logPath: string; + + /** + * unix ms + * + * @generated from field: int64 started_at = 8; + */ + startedAt: bigint; + + /** + * unix ms + * + * @generated from field: optional int64 finished_at = 9; + */ + finishedAt?: bigint | undefined; + + /** + * absent for restored/pid-managed entries + * + * @generated from field: optional int32 exit_code = 10; + */ + exitCode?: number | undefined; + + /** + * @generated from field: bool running = 11; + */ + running: boolean; + + /** + * survives LiveAgent exit + * + * @generated from field: bool isolated = 12; + */ + isolated: boolean; + + /** + * recovered after restart; managed by pid + * + * @generated from field: bool restored = 13; + */ + restored: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ManagedProcessRecord. + * Use `create(ManagedProcessRecordSchema)` to create a new message. + */ +export const ManagedProcessRecordSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 23); + +/** + * @generated from message liveagent.gateway.v2.ManagedProcessSnapshot + */ +export type ManagedProcessSnapshot = Message<"liveagent.gateway.v2.ManagedProcessSnapshot"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.ManagedProcessRecord processes = 1; + */ + processes: ManagedProcessRecord[]; + + /** + * agent-side monotonic, restart-safe + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ManagedProcessSnapshot. + * Use `create(ManagedProcessSnapshotSchema)` to create a new message. + */ +export const ManagedProcessSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 24); + +/** + * @generated from message liveagent.gateway.v2.ManagedProcessRequest + */ +export type ManagedProcessRequest = Message<"liveagent.gateway.v2.ManagedProcessRequest"> & { + /** + * "snapshot" | "stop" | "read_log" | "clear" + * + * @generated from field: string action = 1; + */ + action: string; + + /** + * stop/read_log; optional for clear + * + * @generated from field: string process_id = 2; + */ + processId: string; + + /** + * read_log only; 0 = default + * + * @generated from field: uint32 max_bytes = 3; + */ + maxBytes: number; +}; + +/** + * Describes the message liveagent.gateway.v2.ManagedProcessRequest. + * Use `create(ManagedProcessRequestSchema)` to create a new message. + */ +export const ManagedProcessRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 25); + +/** + * @generated from message liveagent.gateway.v2.ManagedProcessResponse + */ +export type ManagedProcessResponse = Message<"liveagent.gateway.v2.ManagedProcessResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * set for snapshot/stop/clear + * + * @generated from field: liveagent.gateway.v2.ManagedProcessSnapshot snapshot = 2; + */ + snapshot?: ManagedProcessSnapshot | undefined; + + /** + * read_log only + * + * @generated from field: string log_content = 3; + */ + logContent: string; + + /** + * read_log only + * + * @generated from field: string log_path = 4; + */ + logPath: string; + + /** + * read_log only + * + * @generated from field: bool log_truncated = 5; + */ + logTruncated: boolean; + + /** + * stop only + * + * @generated from field: bool stopped = 6; + */ + stopped: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ManagedProcessResponse. + * Use `create(ManagedProcessResponseSchema)` to create a new message. + */ +export const ManagedProcessResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 26); + +/** + * @generated from message liveagent.gateway.v2.MemoryManageRequest + */ +export type MemoryManageRequest = Message<"liveagent.gateway.v2.MemoryManageRequest"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string args_json = 2; + */ + argsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.MemoryManageRequest. + * Use `create(MemoryManageRequestSchema)` to create a new message. + */ +export const MemoryManageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 27); + +/** + * @generated from message liveagent.gateway.v2.MemoryManageResponse + */ +export type MemoryManageResponse = Message<"liveagent.gateway.v2.MemoryManageResponse"> & { + /** + * @generated from field: string result_json = 1; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.MemoryManageResponse. + * Use `create(MemoryManageResponseSchema)` to create a new message. + */ +export const MemoryManageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 28); + +/** + * @generated from message liveagent.gateway.v2.TerminalRequest + */ +export type TerminalRequest = Message<"liveagent.gateway.v2.TerminalRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: string cwd = 4; + */ + cwd: string; + + /** + * @generated from field: string shell = 5; + */ + shell: string; + + /** + * @generated from field: string title = 6; + */ + title: string; + + /** + * @generated from field: string data = 7; + */ + data: string; + + /** + * @generated from field: uint32 cols = 8; + */ + cols: number; + + /** + * @generated from field: uint32 rows = 9; + */ + rows: number; + + /** + * @generated from field: uint32 max_bytes = 10; + */ + maxBytes: number; + + /** + * @generated from field: string ssh_host_id = 11; + */ + sshHostId: string; + + /** + * @generated from field: string prompt_id = 12; + */ + promptId: string; + + /** + * @generated from field: string prompt_answer = 13; + */ + promptAnswer: string; + + /** + * @generated from field: bool trust_host_key = 14; + */ + trustHostKey: boolean; + + /** + * @generated from field: bool sftp_enabled = 15; + */ + sftpEnabled: boolean; + + /** + * @generated from field: string tab_id = 16; + */ + tabId: string; + + /** + * @generated from field: string tab_kind = 17; + */ + tabKind: string; + + /** + * @generated from field: string remote_host = 18; + */ + remoteHost: string; + + /** + * @generated from field: uint32 remote_port = 19; + */ + remotePort: number; + + /** + * @generated from field: uint32 local_port = 20; + */ + localPort: number; + + /** + * @generated from field: string forward_id = 21; + */ + forwardId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalRequest. + * Use `create(TerminalRequestSchema)` to create a new message. + */ +export const TerminalRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 29); + +/** + * @generated from message liveagent.gateway.v2.TerminalSession + */ +export type TerminalSession = Message<"liveagent.gateway.v2.TerminalSession"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string project_path_key = 2; + */ + projectPathKey: string; + + /** + * @generated from field: string cwd = 3; + */ + cwd: string; + + /** + * @generated from field: string shell = 4; + */ + shell: string; + + /** + * @generated from field: string title = 5; + */ + title: string; + + /** + * @generated from field: uint32 pid = 6; + */ + pid: number; + + /** + * @generated from field: uint32 cols = 7; + */ + cols: number; + + /** + * @generated from field: uint32 rows = 8; + */ + rows: number; + + /** + * @generated from field: uint64 created_at = 9; + */ + createdAt: bigint; + + /** + * @generated from field: uint64 updated_at = 10; + */ + updatedAt: bigint; + + /** + * @generated from field: uint64 finished_at = 11; + */ + finishedAt: bigint; + + /** + * @generated from field: int32 exit_code = 12; + */ + exitCode: number; + + /** + * @generated from field: bool running = 13; + */ + running: boolean; + + /** + * @generated from field: string kind = 14; + */ + kind: string; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSshMetadata ssh = 15; + */ + ssh?: TerminalSshMetadata | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSession. + * Use `create(TerminalSessionSchema)` to create a new message. + */ +export const TerminalSessionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 30); + +/** + * @generated from message liveagent.gateway.v2.TerminalSshMetadata + */ +export type TerminalSshMetadata = Message<"liveagent.gateway.v2.TerminalSshMetadata"> & { + /** + * @generated from field: string host_id = 1; + */ + hostId: string; + + /** + * @generated from field: string host_name = 2; + */ + hostName: string; + + /** + * @generated from field: string username = 3; + */ + username: string; + + /** + * @generated from field: string host = 4; + */ + host: string; + + /** + * @generated from field: uint32 port = 5; + */ + port: number; + + /** + * @generated from field: string auth_type = 6; + */ + authType: string; + + /** + * @generated from field: string status = 7; + */ + status: string; + + /** + * @generated from field: uint32 reconnect_attempt = 8; + */ + reconnectAttempt: number; + + /** + * @generated from field: uint32 reconnect_max_attempts = 9; + */ + reconnectMaxAttempts: number; + + /** + * @generated from field: bool sftp_enabled = 10; + */ + sftpEnabled: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSshMetadata. + * Use `create(TerminalSshMetadataSchema)` to create a new message. + */ +export const TerminalSshMetadataSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 31); + +/** + * @generated from message liveagent.gateway.v2.SftpRequest + */ +export type SftpRequest = Message<"liveagent.gateway.v2.SftpRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: string workdir = 4; + */ + workdir: string; + + /** + * @generated from field: string local_path = 5; + */ + localPath: string; + + /** + * @generated from field: string remote_path = 6; + */ + remotePath: string; + + /** + * @generated from field: string from_path = 7; + */ + fromPath: string; + + /** + * @generated from field: string to_path = 8; + */ + toPath: string; + + /** + * @generated from field: string direction = 9; + */ + direction: string; + + /** + * @generated from field: string target_path = 10; + */ + targetPath: string; + + /** + * @generated from field: bool recursive = 11; + */ + recursive: boolean; + + /** + * @generated from field: bool overwrite = 12; + */ + overwrite: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.SftpRequest. + * Use `create(SftpRequestSchema)` to create a new message. + */ +export const SftpRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 32); + +/** + * @generated from message liveagent.gateway.v2.SftpEntry + */ +export type SftpEntry = Message<"liveagent.gateway.v2.SftpEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string kind = 3; + */ + kind: string; + + /** + * @generated from field: uint64 size_bytes = 4; + */ + sizeBytes: bigint; + + /** + * @generated from field: uint64 mtime = 5; + */ + mtime: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.SftpEntry. + * Use `create(SftpEntrySchema)` to create a new message. + */ +export const SftpEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 33); + +/** + * @generated from message liveagent.gateway.v2.SftpTransfer + */ +export type SftpTransfer = Message<"liveagent.gateway.v2.SftpTransfer"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string direction = 3; + */ + direction: string; + + /** + * @generated from field: string status = 4; + */ + status: string; + + /** + * @generated from field: string source_path = 5; + */ + sourcePath: string; + + /** + * @generated from field: string target_path = 6; + */ + targetPath: string; + + /** + * @generated from field: string current_path = 7; + */ + currentPath: string; + + /** + * @generated from field: uint64 bytes_done = 8; + */ + bytesDone: bigint; + + /** + * @generated from field: uint64 bytes_total = 9; + */ + bytesTotal: bigint; + + /** + * @generated from field: uint32 files_done = 10; + */ + filesDone: number; + + /** + * @generated from field: uint32 files_total = 11; + */ + filesTotal: number; + + /** + * @generated from field: string error = 12; + */ + error: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SftpTransfer. + * Use `create(SftpTransferSchema)` to create a new message. + */ +export const SftpTransferSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 34); + +/** + * @generated from message liveagent.gateway.v2.SftpResponse + */ +export type SftpResponse = Message<"liveagent.gateway.v2.SftpResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: repeated liveagent.gateway.v2.SftpEntry entries = 3; + */ + entries: SftpEntry[]; + + /** + * @generated from field: liveagent.gateway.v2.SftpEntry entry = 4; + */ + entry?: SftpEntry | undefined; + + /** + * @generated from field: bool exists = 5; + */ + exists: boolean; + + /** + * @generated from field: liveagent.gateway.v2.SftpTransfer transfer = 6; + */ + transfer?: SftpTransfer | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.SftpResponse. + * Use `create(SftpResponseSchema)` to create a new message. + */ +export const SftpResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 35); + +/** + * @generated from message liveagent.gateway.v2.SftpEvent + */ +export type SftpEvent = Message<"liveagent.gateway.v2.SftpEvent"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: liveagent.gateway.v2.SftpTransfer transfer = 2; + */ + transfer?: SftpTransfer | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.SftpEvent. + * Use `create(SftpEventSchema)` to create a new message. + */ +export const SftpEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 36); + +/** + * @generated from message liveagent.gateway.v2.TerminalSshPrompt + */ +export type TerminalSshPrompt = Message<"liveagent.gateway.v2.TerminalSshPrompt"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: string host_id = 3; + */ + hostId: string; + + /** + * @generated from field: string host_name = 4; + */ + hostName: string; + + /** + * @generated from field: string host = 5; + */ + host: string; + + /** + * @generated from field: uint32 port = 6; + */ + port: number; + + /** + * @generated from field: string message = 7; + */ + message: string; + + /** + * @generated from field: string fingerprint_sha256 = 8; + */ + fingerprintSha256: string; + + /** + * @generated from field: string key_type = 9; + */ + keyType: string; + + /** + * @generated from field: bool answer_echo = 10; + */ + answerEcho: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSshPrompt. + * Use `create(TerminalSshPromptSchema)` to create a new message. + */ +export const TerminalSshPromptSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 37); + +/** + * @generated from message liveagent.gateway.v2.TerminalShellOption + */ +export type TerminalShellOption = Message<"liveagent.gateway.v2.TerminalShellOption"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string label = 2; + */ + label: string; + + /** + * @generated from field: string command = 3; + */ + command: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalShellOption. + * Use `create(TerminalShellOptionSchema)` to create a new message. + */ +export const TerminalShellOptionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 38); + +/** + * @generated from message liveagent.gateway.v2.TerminalSshTab + */ +export type TerminalSshTab = Message<"liveagent.gateway.v2.TerminalSshTab"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: string kind = 4; + */ + kind: string; + + /** + * @generated from field: uint64 created_at = 5; + */ + createdAt: bigint; + + /** + * @generated from field: uint64 updated_at = 6; + */ + updatedAt: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSshTab. + * Use `create(TerminalSshTabSchema)` to create a new message. + */ +export const TerminalSshTabSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 39); + +/** + * @generated from message liveagent.gateway.v2.TerminalSshTabsSnapshot + */ +export type TerminalSshTabsSnapshot = Message<"liveagent.gateway.v2.TerminalSshTabsSnapshot"> & { + /** + * @generated from field: string project_path_key = 1; + */ + projectPathKey: string; + + /** + * @generated from field: repeated liveagent.gateway.v2.TerminalSshTab tabs = 2; + */ + tabs: TerminalSshTab[]; + + /** + * @generated from field: uint64 revision = 4; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSshTabsSnapshot. + * Use `create(TerminalSshTabsSnapshotSchema)` to create a new message. + */ +export const TerminalSshTabsSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 40); + +/** + * @generated from message liveagent.gateway.v2.TerminalSshLocalForward + */ +export type TerminalSshLocalForward = Message<"liveagent.gateway.v2.TerminalSshLocalForward"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: string local_host = 4; + */ + localHost: string; + + /** + * @generated from field: uint32 local_port = 5; + */ + localPort: number; + + /** + * @generated from field: string address = 6; + */ + address: string; + + /** + * @generated from field: string remote_host = 7; + */ + remoteHost: string; + + /** + * @generated from field: uint32 remote_port = 8; + */ + remotePort: number; + + /** + * @generated from field: string status = 9; + */ + status: string; + + /** + * @generated from field: uint64 created_at = 10; + */ + createdAt: bigint; + + /** + * @generated from field: uint64 updated_at = 11; + */ + updatedAt: bigint; + + /** + * @generated from field: string error = 12; + */ + error: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSshLocalForward. + * Use `create(TerminalSshLocalForwardSchema)` to create a new message. + */ +export const TerminalSshLocalForwardSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 41); + +/** + * @generated from message liveagent.gateway.v2.TerminalSshLocalForwardsSnapshot + */ +export type TerminalSshLocalForwardsSnapshot = Message<"liveagent.gateway.v2.TerminalSshLocalForwardsSnapshot"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.TerminalSshLocalForward forwards = 1; + */ + forwards: TerminalSshLocalForward[]; + + /** + * @generated from field: uint64 revision = 2; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSshLocalForwardsSnapshot. + * Use `create(TerminalSshLocalForwardsSnapshotSchema)` to create a new message. + */ +export const TerminalSshLocalForwardsSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 42); + +/** + * @generated from message liveagent.gateway.v2.TerminalSshLocalForwardAction + */ +export type TerminalSshLocalForwardAction = Message<"liveagent.gateway.v2.TerminalSshLocalForwardAction"> & { + /** + * @generated from field: liveagent.gateway.v2.TerminalSshLocalForward forward = 1; + */ + forward?: TerminalSshLocalForward | undefined; + + /** + * @generated from field: uint64 revision = 2; + */ + revision: bigint; + + /** + * Only set on events: started | stopped | failed. + * + * @generated from field: string kind = 3; + */ + kind: string; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalSshLocalForwardAction. + * Use `create(TerminalSshLocalForwardActionSchema)` to create a new message. + */ +export const TerminalSshLocalForwardActionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 43); + +/** + * @generated from message liveagent.gateway.v2.TerminalResponse + */ +export type TerminalResponse = Message<"liveagent.gateway.v2.TerminalResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: repeated liveagent.gateway.v2.TerminalSession sessions = 2; + */ + sessions: TerminalSession[]; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSession session = 3; + */ + session?: TerminalSession | undefined; + + /** + * @generated from field: bytes output = 4; + */ + output: Uint8Array; + + /** + * @generated from field: bool truncated = 5; + */ + truncated: boolean; + + /** + * @generated from field: repeated liveagent.gateway.v2.TerminalShellOption shell_options = 6; + */ + shellOptions: TerminalShellOption[]; + + /** + * @generated from field: string default_shell = 7; + */ + defaultShell: string; + + /** + * @generated from field: uint64 output_start_offset = 8; + */ + outputStartOffset: bigint; + + /** + * @generated from field: uint64 output_end_offset = 9; + */ + outputEndOffset: bigint; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSshPrompt ssh_prompt = 10; + */ + sshPrompt?: TerminalSshPrompt | undefined; + + /** + * @generated from field: uint32 latency_ms = 11; + */ + latencyMs: number; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSshTabsSnapshot ssh_tabs = 12; + */ + sshTabs?: TerminalSshTabsSnapshot | undefined; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSshLocalForwardsSnapshot ssh_local_forwards = 13; + */ + sshLocalForwards?: TerminalSshLocalForwardsSnapshot | undefined; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSshLocalForwardAction ssh_local_forward = 14; + */ + sshLocalForward?: TerminalSshLocalForwardAction | undefined; + + /** + * @generated from field: bool ssh_local_forward_port_available = 15; + */ + sshLocalForwardPortAvailable: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalResponse. + * Use `create(TerminalResponseSchema)` to create a new message. + */ +export const TerminalResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 44); + +/** + * @generated from message liveagent.gateway.v2.TerminalEvent + */ +export type TerminalEvent = Message<"liveagent.gateway.v2.TerminalEvent"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSession session = 4; + */ + session?: TerminalSession | undefined; + + /** + * @generated from field: bytes data = 5; + */ + data: Uint8Array; + + /** + * @generated from field: uint64 output_start_offset = 6; + */ + outputStartOffset: bigint; + + /** + * @generated from field: uint64 output_end_offset = 7; + */ + outputEndOffset: bigint; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSshTabsSnapshot ssh_tabs = 8; + */ + sshTabs?: TerminalSshTabsSnapshot | undefined; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSshLocalForwardAction ssh_local_forward = 9; + */ + sshLocalForward?: TerminalSshLocalForwardAction | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalEvent. + * Use `create(TerminalEventSchema)` to create a new message. + */ +export const TerminalEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 45); + +/** + * @generated from message liveagent.gateway.v2.TerminalStreamFrame + */ +export type TerminalStreamFrame = Message<"liveagent.gateway.v2.TerminalStreamFrame"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: string stream_id = 2; + */ + streamId: string; + + /** + * @generated from field: string session_id = 3; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 4; + */ + projectPathKey: string; + + /** + * @generated from field: uint64 seq = 5; + */ + seq: bigint; + + /** + * @generated from field: uint64 start_offset = 6; + */ + startOffset: bigint; + + /** + * @generated from field: uint64 end_offset = 7; + */ + endOffset: bigint; + + /** + * @generated from field: uint32 cols = 8; + */ + cols: number; + + /** + * @generated from field: uint32 rows = 9; + */ + rows: number; + + /** + * @generated from field: uint32 max_bytes = 10; + */ + maxBytes: number; + + /** + * @generated from field: bool truncated = 11; + */ + truncated: boolean; + + /** + * @generated from field: string error = 12; + */ + error: string; + + /** + * @generated from field: liveagent.gateway.v2.TerminalSession session = 13; + */ + session?: TerminalSession | undefined; + + /** + * @generated from field: bytes data = 14; + */ + data: Uint8Array; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalStreamFrame. + * Use `create(TerminalStreamFrameSchema)` to create a new message. + */ +export const TerminalStreamFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 46); + +/** + * @generated from message liveagent.gateway.v2.GitRequest + */ +export type GitRequest = Message<"liveagent.gateway.v2.GitRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string workdir = 2; + */ + workdir: string; + + /** + * @generated from field: string args_json = 3; + */ + argsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.GitRequest. + * Use `create(GitRequestSchema)` to create a new message. + */ +export const GitRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 47); + +/** + * @generated from message liveagent.gateway.v2.GitResponse + */ +export type GitResponse = Message<"liveagent.gateway.v2.GitResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string result_json = 2; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.GitResponse. + * Use `create(GitResponseSchema)` to create a new message. + */ +export const GitResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 48); + +/** + * @generated from message liveagent.gateway.v2.ChatRequest + */ +export type ChatRequest = Message<"liveagent.gateway.v2.ChatRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * @generated from field: liveagent.gateway.v2.ChatSelectedModel selected_model = 3; + */ + selectedModel?: ChatSelectedModel | undefined; + + /** + * @generated from field: string execution_mode = 4; + */ + executionMode: string; + + /** + * @generated from field: string workdir = 5; + */ + workdir: string; + + /** + * @generated from field: repeated liveagent.gateway.v2.ChatUploadedFile uploaded_files = 7; + */ + uploadedFiles: ChatUploadedFile[]; + + /** + * @generated from field: string client_request_id = 8; + */ + clientRequestId: string; + + /** + * @generated from field: liveagent.gateway.v2.ChatRuntimeControls runtime_controls = 9; + */ + runtimeControls?: ChatRuntimeControls | undefined; + + /** + * @generated from field: string queue_policy = 10; + */ + queuePolicy: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRequest. + * Use `create(ChatRequestSchema)` to create a new message. + */ +export const ChatRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 49); + +/** + * @generated from message liveagent.gateway.v2.ChatMessageRef + */ +export type ChatMessageRef = Message<"liveagent.gateway.v2.ChatMessageRef"> & { + /** + * @generated from field: int32 segment_index = 1; + */ + segmentIndex: number; + + /** + * @generated from field: int32 message_index = 2; + */ + messageIndex: number; + + /** + * @generated from field: string segment_id = 3; + */ + segmentId: string; + + /** + * @generated from field: string message_id = 4; + */ + messageId: string; + + /** + * @generated from field: string role = 5; + */ + role: string; + + /** + * @generated from field: string content_hash = 6; + */ + contentHash: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatMessageRef. + * Use `create(ChatMessageRefSchema)` to create a new message. + */ +export const ChatMessageRefSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 50); + +/** + * @generated from message liveagent.gateway.v2.CancelChatRequest + */ +export type CancelChatRequest = Message<"liveagent.gateway.v2.CancelChatRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * 可选运行 id 提示:v2 浏览器链路用它消除同会话并发运行的歧义;桌面端可忽略。 + * + * @generated from field: string run_id = 2; + */ + runId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.CancelChatRequest. + * Use `create(CancelChatRequestSchema)` to create a new message. + */ +export const CancelChatRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 51); + +/** + * @generated from message liveagent.gateway.v2.ChatCommandRequest + */ +export type ChatCommandRequest = Message<"liveagent.gateway.v2.ChatCommandRequest"> & { + /** + * @generated from field: string type = 1; + */ + type: string; + + /** + * @generated from field: liveagent.gateway.v2.ChatRequest request = 2; + */ + request?: ChatRequest | undefined; + + /** + * @generated from field: liveagent.gateway.v2.ChatMessageRef base_message_ref = 3; + */ + baseMessageRef?: ChatMessageRef | undefined; + + /** + * @generated from field: liveagent.gateway.v2.CancelChatRequest cancel = 4; + */ + cancel?: CancelChatRequest | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatCommandRequest. + * Use `create(ChatCommandRequestSchema)` to create a new message. + */ +export const ChatCommandRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 52); + +/** + * @generated from message liveagent.gateway.v2.ChatQueueRequest + */ +export type ChatQueueRequest = Message<"liveagent.gateway.v2.ChatQueueRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: string item_id = 3; + */ + itemId: string; + + /** + * @generated from field: string direction = 4; + */ + direction: string; + + /** + * @generated from field: uint64 revision = 5; + */ + revision: bigint; + + /** + * @generated from field: string draft_json = 6; + */ + draftJson: string; + + /** + * @generated from field: string uploaded_files_json = 7; + */ + uploadedFilesJson: string; + + /** + * @generated from field: string request_json = 8; + */ + requestJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatQueueRequest. + * Use `create(ChatQueueRequestSchema)` to create a new message. + */ +export const ChatQueueRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 53); + +/** + * @generated from message liveagent.gateway.v2.ChatQueueResponse + */ +export type ChatQueueResponse = Message<"liveagent.gateway.v2.ChatQueueResponse"> & { + /** + * @generated from field: bool accepted = 1; + */ + accepted: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * @generated from field: string snapshot_json = 3; + */ + snapshotJson: string; + + /** + * @generated from field: string item_json = 4; + */ + itemJson: string; + + /** + * @generated from field: string error_code = 5; + */ + errorCode: string; + + /** + * @generated from field: uint64 revision = 6; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatQueueResponse. + * Use `create(ChatQueueResponseSchema)` to create a new message. + */ +export const ChatQueueResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 54); + +/** + * @generated from message liveagent.gateway.v2.ChatQueueEvent + */ +export type ChatQueueEvent = Message<"liveagent.gateway.v2.ChatQueueEvent"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string snapshot_json = 2; + */ + snapshotJson: string; + + /** + * @generated from field: uint64 revision = 3; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatQueueEvent. + * Use `create(ChatQueueEventSchema)` to create a new message. + */ +export const ChatQueueEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 55); + +/** + * @generated from message liveagent.gateway.v2.ChatEvent + */ +export type ChatEvent = Message<"liveagent.gateway.v2.ChatEvent"> & { + /** + * @generated from field: liveagent.gateway.v2.ChatEvent.ChatEventType type = 1; + */ + type: ChatEvent_ChatEventType; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: string data = 3; + */ + data: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatEvent. + * Use `create(ChatEventSchema)` to create a new message. + */ +export const ChatEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 56); + +/** + * @generated from enum liveagent.gateway.v2.ChatEvent.ChatEventType + */ +export enum ChatEvent_ChatEventType { + /** + * @generated from enum value: TOKEN = 0; + */ + TOKEN = 0, + + /** + * @generated from enum value: THINKING = 1; + */ + THINKING = 1, + + /** + * @generated from enum value: TOOL_CALL = 2; + */ + TOOL_CALL = 2, + + /** + * @generated from enum value: TOOL_RESULT = 3; + */ + TOOL_RESULT = 3, + + /** + * @generated from enum value: DONE = 4; + */ + DONE = 4, + + /** + * @generated from enum value: ERROR = 5; + */ + ERROR = 5, + + /** + * @generated from enum value: TOOL_STATUS = 6; + */ + TOOL_STATUS = 6, + + /** + * @generated from enum value: HOSTED_SEARCH = 7; + */ + HOSTED_SEARCH = 7, + + /** + * @generated from enum value: USER_MESSAGE = 8; + */ + USER_MESSAGE = 8, +} + +/** + * Describes the enum liveagent.gateway.v2.ChatEvent.ChatEventType. + */ +export const ChatEvent_ChatEventTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v2_gateway, 56, 0); + +/** + * @generated from message liveagent.gateway.v2.ChatControlEvent + */ +export type ChatControlEvent = Message<"liveagent.gateway.v2.ChatControlEvent"> & { + /** + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from field: string client_request_id = 2; + */ + clientRequestId: string; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; + + /** + * @generated from field: int64 run_epoch = 4; + */ + runEpoch: bigint; + + /** + * @generated from field: string type = 5; + */ + type: string; + + /** + * @generated from field: string state = 6; + */ + state: string; + + /** + * @generated from field: string error_code = 7; + */ + errorCode: string; + + /** + * @generated from field: string message = 8; + */ + message: string; + + /** + * @generated from field: int64 seq = 9; + */ + seq: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatControlEvent. + * Use `create(ChatControlEventSchema)` to create a new message. + */ +export const ChatControlEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 57); + +/** + * @generated from message liveagent.gateway.v2.ChatRuntimeSnapshot + */ +export type ChatRuntimeSnapshot = Message<"liveagent.gateway.v2.ChatRuntimeSnapshot"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string run_id = 2; + */ + runId: string; + + /** + * @generated from field: string client_request_id = 3; + */ + clientRequestId: string; + + /** + * @generated from field: string worker_id = 4; + */ + workerId: string; + + /** + * @generated from field: string state = 5; + */ + state: string; + + /** + * @generated from field: string cwd = 6; + */ + cwd: string; + + /** + * @generated from field: int64 updated_at = 7; + */ + updatedAt: bigint; + + /** + * @generated from field: int64 revision = 8; + */ + revision: bigint; + + /** + * @generated from field: string entries_json = 9; + */ + entriesJson: string; + + /** + * @generated from field: string tool_status = 10; + */ + toolStatus: string; + + /** + * @generated from field: bool tool_status_is_compaction = 11; + */ + toolStatusIsCompaction: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRuntimeSnapshot. + * Use `create(ChatRuntimeSnapshotSchema)` to create a new message. + */ +export const ChatRuntimeSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 58); + +/** + * @generated from message liveagent.gateway.v2.RuntimeStatusEvent + */ +export type RuntimeStatusEvent = Message<"liveagent.gateway.v2.RuntimeStatusEvent"> & { + /** + * @generated from field: string worker_id = 1; + */ + workerId: string; + + /** + * @generated from field: string state = 2; + */ + state: string; + + /** + * @generated from field: bool visible = 3; + */ + visible: boolean; + + /** + * @generated from field: uint32 active_run_count = 4; + */ + activeRunCount: number; + + /** + * @generated from field: int64 timestamp = 5; + */ + timestamp: bigint; + + /** + * @generated from field: repeated liveagent.gateway.v2.ChatRunReport active_runs = 6; + */ + activeRuns: ChatRunReport[]; + + /** + * @generated from field: repeated liveagent.gateway.v2.ChatRunReport finished_runs = 7; + */ + finishedRuns: ChatRunReport[]; +}; + +/** + * Describes the message liveagent.gateway.v2.RuntimeStatusEvent. + * Use `create(RuntimeStatusEventSchema)` to create a new message. + */ +export const RuntimeStatusEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 59); + +/** + * @generated from message liveagent.gateway.v2.ChatRunReport + */ +export type ChatRunReport = Message<"liveagent.gateway.v2.ChatRunReport"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: string state = 3; + */ + state: string; + + /** + * @generated from field: string error_code = 4; + */ + errorCode: string; + + /** + * @generated from field: string message = 5; + */ + message: string; + + /** + * @generated from field: int64 updated_at = 6; + */ + updatedAt: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRunReport. + * Use `create(ChatRunReportSchema)` to create a new message. + */ +export const ChatRunReportSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 60); + +/** + * @generated from message liveagent.gateway.v2.CronManageRequest + */ +export type CronManageRequest = Message<"liveagent.gateway.v2.CronManageRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string task_id = 2; + */ + taskId: string; + + /** + * @generated from field: string task_json = 3; + */ + taskJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.CronManageRequest. + * Use `create(CronManageRequestSchema)` to create a new message. + */ +export const CronManageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 61); + +/** + * @generated from message liveagent.gateway.v2.CronManageResponse + */ +export type CronManageResponse = Message<"liveagent.gateway.v2.CronManageResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string result_json = 2; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.CronManageResponse. + * Use `create(CronManageResponseSchema)` to create a new message. + */ +export const CronManageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 62); + +/** + * @generated from message liveagent.gateway.v2.HistoryListRequest + */ +export type HistoryListRequest = Message<"liveagent.gateway.v2.HistoryListRequest"> & { + /** + * @generated from field: int32 page = 1; + */ + page: number; + + /** + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * @generated from field: string cwd = 3; + */ + cwd: string; + + /** + * @generated from field: bool cwd_empty = 4; + */ + cwdEmpty: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryListRequest. + * Use `create(HistoryListRequestSchema)` to create a new message. + */ +export const HistoryListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 63); + +/** + * @generated from message liveagent.gateway.v2.HistoryListResponse + */ +export type HistoryListResponse = Message<"liveagent.gateway.v2.HistoryListResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.ConversationSummary conversations = 1; + */ + conversations: ConversationSummary[]; + + /** + * @generated from field: int32 total_count = 2; + */ + totalCount: number; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryListResponse. + * Use `create(HistoryListResponseSchema)` to create a new message. + */ +export const HistoryListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 64); + +/** + * @generated from message liveagent.gateway.v2.ConversationSummary + */ +export type ConversationSummary = Message<"liveagent.gateway.v2.ConversationSummary"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string title = 2; + */ + title: string; + + /** + * @generated from field: int64 created_at = 3; + */ + createdAt: bigint; + + /** + * @generated from field: int64 updated_at = 4; + */ + updatedAt: bigint; + + /** + * @generated from field: int32 message_count = 5; + */ + messageCount: number; + + /** + * @generated from field: string provider_id = 6; + */ + providerId: string; + + /** + * @generated from field: string model = 7; + */ + model: string; + + /** + * @generated from field: string session_id = 8; + */ + sessionId: string; + + /** + * @generated from field: string cwd = 9; + */ + cwd: string; + + /** + * @generated from field: bool is_pinned = 10; + */ + isPinned: boolean; + + /** + * @generated from field: int64 pinned_at = 11; + */ + pinnedAt: bigint; + + /** + * @generated from field: bool is_shared = 12; + */ + isShared: boolean; + + /** + * @generated from field: string selected_model_json = 13; + */ + selectedModelJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ConversationSummary. + * Use `create(ConversationSummarySchema)` to create a new message. + */ +export const ConversationSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 65); + +/** + * @generated from message liveagent.gateway.v2.HistoryGetRequest + */ +export type HistoryGetRequest = Message<"liveagent.gateway.v2.HistoryGetRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: int32 max_messages = 2; + */ + maxMessages: number; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryGetRequest. + * Use `create(HistoryGetRequestSchema)` to create a new message. + */ +export const HistoryGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 66); + +/** + * @generated from message liveagent.gateway.v2.HistoryGetResponse + */ +export type HistoryGetResponse = Message<"liveagent.gateway.v2.HistoryGetResponse"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string messages_json = 2; + */ + messagesJson: string; + + /** + * @generated from field: int32 total_message_count = 3; + */ + totalMessageCount: number; + + /** + * @generated from field: int32 returned_message_count = 4; + */ + returnedMessageCount: number; + + /** + * @generated from field: bool has_more = 5; + */ + hasMore: boolean; + + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 6; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryGetResponse. + * Use `create(HistoryGetResponseSchema)` to create a new message. + */ +export const HistoryGetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 67); + +/** + * @generated from message liveagent.gateway.v2.HistoryPrefixRequest + */ +export type HistoryPrefixRequest = Message<"liveagent.gateway.v2.HistoryPrefixRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: int32 max_messages = 2; + */ + maxMessages: number; + + /** + * @generated from field: liveagent.gateway.v2.ChatMessageRef base_message_ref = 3; + */ + baseMessageRef?: ChatMessageRef | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryPrefixRequest. + * Use `create(HistoryPrefixRequestSchema)` to create a new message. + */ +export const HistoryPrefixRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 68); + +/** + * @generated from message liveagent.gateway.v2.HistoryPrefixResponse + */ +export type HistoryPrefixResponse = Message<"liveagent.gateway.v2.HistoryPrefixResponse"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string messages_json = 2; + */ + messagesJson: string; + + /** + * @generated from field: int32 total_message_count = 3; + */ + totalMessageCount: number; + + /** + * @generated from field: int32 returned_message_count = 4; + */ + returnedMessageCount: number; + + /** + * @generated from field: bool has_more = 5; + */ + hasMore: boolean; + + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 6; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryPrefixResponse. + * Use `create(HistoryPrefixResponseSchema)` to create a new message. + */ +export const HistoryPrefixResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 69); + +/** + * @generated from message liveagent.gateway.v2.HistoryRenameRequest + */ +export type HistoryRenameRequest = Message<"liveagent.gateway.v2.HistoryRenameRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string title = 2; + */ + title: string; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryRenameRequest. + * Use `create(HistoryRenameRequestSchema)` to create a new message. + */ +export const HistoryRenameRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 70); + +/** + * @generated from message liveagent.gateway.v2.HistoryRenameResponse + */ +export type HistoryRenameResponse = Message<"liveagent.gateway.v2.HistoryRenameResponse"> & { + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 1; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryRenameResponse. + * Use `create(HistoryRenameResponseSchema)` to create a new message. + */ +export const HistoryRenameResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 71); + +/** + * Copies the conversation prefix up to and including the assistant response + * that answers the anchored user message (base_message_ref) into a brand-new + * conversation titled "新分支". + * + * @generated from message liveagent.gateway.v2.HistoryBranchRequest + */ +export type HistoryBranchRequest = Message<"liveagent.gateway.v2.HistoryBranchRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: liveagent.gateway.v2.ChatMessageRef base_message_ref = 2; + */ + baseMessageRef?: ChatMessageRef | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryBranchRequest. + * Use `create(HistoryBranchRequestSchema)` to create a new message. + */ +export const HistoryBranchRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 72); + +/** + * @generated from message liveagent.gateway.v2.HistoryBranchResponse + */ +export type HistoryBranchResponse = Message<"liveagent.gateway.v2.HistoryBranchResponse"> & { + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 1; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryBranchResponse. + * Use `create(HistoryBranchResponseSchema)` to create a new message. + */ +export const HistoryBranchResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 73); + +/** + * @generated from message liveagent.gateway.v2.HistoryPinRequest + */ +export type HistoryPinRequest = Message<"liveagent.gateway.v2.HistoryPinRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: bool is_pinned = 2; + */ + isPinned: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryPinRequest. + * Use `create(HistoryPinRequestSchema)` to create a new message. + */ +export const HistoryPinRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 74); + +/** + * @generated from message liveagent.gateway.v2.HistoryPinResponse + */ +export type HistoryPinResponse = Message<"liveagent.gateway.v2.HistoryPinResponse"> & { + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 1; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryPinResponse. + * Use `create(HistoryPinResponseSchema)` to create a new message. + */ +export const HistoryPinResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 75); + +/** + * @generated from message liveagent.gateway.v2.HistoryShareStatus + */ +export type HistoryShareStatus = Message<"liveagent.gateway.v2.HistoryShareStatus"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: bool enabled = 2; + */ + enabled: boolean; + + /** + * @generated from field: string token = 3; + */ + token: string; + + /** + * @generated from field: int64 created_at = 4; + */ + createdAt: bigint; + + /** + * @generated from field: int64 updated_at = 5; + */ + updatedAt: bigint; + + /** + * @generated from field: bool redact_tool_content = 6; + */ + redactToolContent: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryShareStatus. + * Use `create(HistoryShareStatusSchema)` to create a new message. + */ +export const HistoryShareStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 76); + +/** + * @generated from message liveagent.gateway.v2.HistoryShareGetRequest + */ +export type HistoryShareGetRequest = Message<"liveagent.gateway.v2.HistoryShareGetRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryShareGetRequest. + * Use `create(HistoryShareGetRequestSchema)` to create a new message. + */ +export const HistoryShareGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 77); + +/** + * @generated from message liveagent.gateway.v2.HistoryShareGetResponse + */ +export type HistoryShareGetResponse = Message<"liveagent.gateway.v2.HistoryShareGetResponse"> & { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareStatus share = 1; + */ + share?: HistoryShareStatus | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryShareGetResponse. + * Use `create(HistoryShareGetResponseSchema)` to create a new message. + */ +export const HistoryShareGetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 78); + +/** + * @generated from message liveagent.gateway.v2.HistoryShareSetRequest + */ +export type HistoryShareSetRequest = Message<"liveagent.gateway.v2.HistoryShareSetRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: bool enabled = 2; + */ + enabled: boolean; + + /** + * @generated from field: optional bool redact_tool_content = 3; + */ + redactToolContent?: boolean | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryShareSetRequest. + * Use `create(HistoryShareSetRequestSchema)` to create a new message. + */ +export const HistoryShareSetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 79); + +/** + * @generated from message liveagent.gateway.v2.HistoryShareSetResponse + */ +export type HistoryShareSetResponse = Message<"liveagent.gateway.v2.HistoryShareSetResponse"> & { + /** + * @generated from field: liveagent.gateway.v2.HistoryShareStatus share = 1; + */ + share?: HistoryShareStatus | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryShareSetResponse. + * Use `create(HistoryShareSetResponseSchema)` to create a new message. + */ +export const HistoryShareSetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 80); + +/** + * @generated from message liveagent.gateway.v2.HistoryShareResolveRequest + */ +export type HistoryShareResolveRequest = Message<"liveagent.gateway.v2.HistoryShareResolveRequest"> & { + /** + * @generated from field: string token = 1; + */ + token: string; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryShareResolveRequest. + * Use `create(HistoryShareResolveRequestSchema)` to create a new message. + */ +export const HistoryShareResolveRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 81); + +/** + * @generated from message liveagent.gateway.v2.HistoryShareResolveResponse + */ +export type HistoryShareResolveResponse = Message<"liveagent.gateway.v2.HistoryShareResolveResponse"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string messages_json = 2; + */ + messagesJson: string; + + /** + * @generated from field: int32 total_message_count = 3; + */ + totalMessageCount: number; + + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 4; + */ + conversation?: ConversationSummary | undefined; + + /** + * @generated from field: bool redact_tool_content = 5; + */ + redactToolContent: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryShareResolveResponse. + * Use `create(HistoryShareResolveResponseSchema)` to create a new message. + */ +export const HistoryShareResolveResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 82); + +/** + * @generated from message liveagent.gateway.v2.HistoryWorkdirsRequest + */ +export type HistoryWorkdirsRequest = Message<"liveagent.gateway.v2.HistoryWorkdirsRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryWorkdirsRequest. + * Use `create(HistoryWorkdirsRequestSchema)` to create a new message. + */ +export const HistoryWorkdirsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 83); + +/** + * @generated from message liveagent.gateway.v2.HistoryWorkdirSummary + */ +export type HistoryWorkdirSummary = Message<"liveagent.gateway.v2.HistoryWorkdirSummary"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: int32 conversation_count = 2; + */ + conversationCount: number; + + /** + * @generated from field: int64 updated_at = 3; + */ + updatedAt: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryWorkdirSummary. + * Use `create(HistoryWorkdirSummarySchema)` to create a new message. + */ +export const HistoryWorkdirSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 84); + +/** + * @generated from message liveagent.gateway.v2.HistoryWorkdirsResponse + */ +export type HistoryWorkdirsResponse = Message<"liveagent.gateway.v2.HistoryWorkdirsResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.HistoryWorkdirSummary workdirs = 1; + */ + workdirs: HistoryWorkdirSummary[]; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryWorkdirsResponse. + * Use `create(HistoryWorkdirsResponseSchema)` to create a new message. + */ +export const HistoryWorkdirsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 85); + +/** + * @generated from message liveagent.gateway.v2.HistoryDeleteRequest + */ +export type HistoryDeleteRequest = Message<"liveagent.gateway.v2.HistoryDeleteRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryDeleteRequest. + * Use `create(HistoryDeleteRequestSchema)` to create a new message. + */ +export const HistoryDeleteRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 86); + +/** + * @generated from message liveagent.gateway.v2.HistoryDeleteResponse + */ +export type HistoryDeleteResponse = Message<"liveagent.gateway.v2.HistoryDeleteResponse"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.HistoryDeleteResponse. + * Use `create(HistoryDeleteResponseSchema)` to create a new message. + */ +export const HistoryDeleteResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 87); + +/** + * @generated from message liveagent.gateway.v2.HistorySyncEvent + */ +export type HistorySyncEvent = Message<"liveagent.gateway.v2.HistorySyncEvent"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 2; + */ + conversation?: ConversationSummary | undefined; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.HistorySyncEvent. + * Use `create(HistorySyncEventSchema)` to create a new message. + */ +export const HistorySyncEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 88); + +/** + * @generated from message liveagent.gateway.v2.ProviderListRequest + */ +export type ProviderListRequest = Message<"liveagent.gateway.v2.ProviderListRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.ProviderListRequest. + * Use `create(ProviderListRequestSchema)` to create a new message. + */ +export const ProviderListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 89); + +/** + * @generated from message liveagent.gateway.v2.ProviderListResponse + */ +export type ProviderListResponse = Message<"liveagent.gateway.v2.ProviderListResponse"> & { + /** + * @generated from field: string providers_json = 1; + */ + providersJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ProviderListResponse. + * Use `create(ProviderListResponseSchema)` to create a new message. + */ +export const ProviderListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 90); + +/** + * @generated from message liveagent.gateway.v2.SettingsGetRequest + */ +export type SettingsGetRequest = Message<"liveagent.gateway.v2.SettingsGetRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.SettingsGetRequest. + * Use `create(SettingsGetRequestSchema)` to create a new message. + */ +export const SettingsGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 91); + +/** + * @generated from message liveagent.gateway.v2.SettingsGetResponse + */ +export type SettingsGetResponse = Message<"liveagent.gateway.v2.SettingsGetResponse"> & { + /** + * @generated from field: string settings_json = 1; + */ + settingsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SettingsGetResponse. + * Use `create(SettingsGetResponseSchema)` to create a new message. + */ +export const SettingsGetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 92); + +/** + * @generated from message liveagent.gateway.v2.SettingsUpdateRequest + */ +export type SettingsUpdateRequest = Message<"liveagent.gateway.v2.SettingsUpdateRequest"> & { + /** + * @generated from field: string settings_json = 1; + */ + settingsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SettingsUpdateRequest. + * Use `create(SettingsUpdateRequestSchema)` to create a new message. + */ +export const SettingsUpdateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 93); + +/** + * @generated from message liveagent.gateway.v2.SettingsUpdateResponse + */ +export type SettingsUpdateResponse = Message<"liveagent.gateway.v2.SettingsUpdateResponse"> & { + /** + * @generated from field: bool accepted = 1; + */ + accepted: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SettingsUpdateResponse. + * Use `create(SettingsUpdateResponseSchema)` to create a new message. + */ +export const SettingsUpdateResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 94); + +/** + * @generated from message liveagent.gateway.v2.SettingsResetSshKnownHostRequest + */ +export type SettingsResetSshKnownHostRequest = Message<"liveagent.gateway.v2.SettingsResetSshKnownHostRequest"> & { + /** + * @generated from field: string host = 1; + */ + host: string; + + /** + * @generated from field: uint32 port = 2; + */ + port: number; +}; + +/** + * Describes the message liveagent.gateway.v2.SettingsResetSshKnownHostRequest. + * Use `create(SettingsResetSshKnownHostRequestSchema)` to create a new message. + */ +export const SettingsResetSshKnownHostRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 95); + +/** + * @generated from message liveagent.gateway.v2.SettingsResetSshKnownHostResponse + */ +export type SettingsResetSshKnownHostResponse = Message<"liveagent.gateway.v2.SettingsResetSshKnownHostResponse"> & { + /** + * @generated from field: uint32 deleted = 1; + */ + deleted: number; +}; + +/** + * Describes the message liveagent.gateway.v2.SettingsResetSshKnownHostResponse. + * Use `create(SettingsResetSshKnownHostResponseSchema)` to create a new message. + */ +export const SettingsResetSshKnownHostResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 96); + +/** + * @generated from message liveagent.gateway.v2.SettingsSyncEvent + */ +export type SettingsSyncEvent = Message<"liveagent.gateway.v2.SettingsSyncEvent"> & { + /** + * @generated from field: string settings_json = 1; + */ + settingsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SettingsSyncEvent. + * Use `create(SettingsSyncEventSchema)` to create a new message. + */ +export const SettingsSyncEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 97); + +/** + * @generated from message liveagent.gateway.v2.SkillFilesListRequest + */ +export type SkillFilesListRequest = Message<"liveagent.gateway.v2.SkillFilesListRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.SkillFilesListRequest. + * Use `create(SkillFilesListRequestSchema)` to create a new message. + */ +export const SkillFilesListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 98); + +/** + * @generated from message liveagent.gateway.v2.SkillFilesListResponse + */ +export type SkillFilesListResponse = Message<"liveagent.gateway.v2.SkillFilesListResponse"> & { + /** + * @generated from field: string root_dir = 1; + */ + rootDir: string; + + /** + * @generated from field: repeated string paths = 2; + */ + paths: string[]; + + /** + * @generated from field: bool truncated = 3; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.SkillFilesListResponse. + * Use `create(SkillFilesListResponseSchema)` to create a new message. + */ +export const SkillFilesListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 99); + +/** + * @generated from message liveagent.gateway.v2.SkillMetadataReadRequest + */ +export type SkillMetadataReadRequest = Message<"liveagent.gateway.v2.SkillMetadataReadRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SkillMetadataReadRequest. + * Use `create(SkillMetadataReadRequestSchema)` to create a new message. + */ +export const SkillMetadataReadRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 100); + +/** + * @generated from message liveagent.gateway.v2.SkillMetadataReadResponse + */ +export type SkillMetadataReadResponse = Message<"liveagent.gateway.v2.SkillMetadataReadResponse"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string description = 2; + */ + description: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SkillMetadataReadResponse. + * Use `create(SkillMetadataReadResponseSchema)` to create a new message. + */ +export const SkillMetadataReadResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 101); + +/** + * @generated from message liveagent.gateway.v2.SkillTextReadRequest + */ +export type SkillTextReadRequest = Message<"liveagent.gateway.v2.SkillTextReadRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: uint32 offset = 2; + */ + offset: number; + + /** + * @generated from field: uint32 length = 3; + */ + length: number; +}; + +/** + * Describes the message liveagent.gateway.v2.SkillTextReadRequest. + * Use `create(SkillTextReadRequestSchema)` to create a new message. + */ +export const SkillTextReadRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 102); + +/** + * @generated from message liveagent.gateway.v2.SkillTextReadResponse + */ +export type SkillTextReadResponse = Message<"liveagent.gateway.v2.SkillTextReadResponse"> & { + /** + * @generated from field: string content = 1; + */ + content: string; + + /** + * @generated from field: bool truncated = 2; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.SkillTextReadResponse. + * Use `create(SkillTextReadResponseSchema)` to create a new message. + */ +export const SkillTextReadResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 103); + +/** + * @generated from message liveagent.gateway.v2.SkillManageRequest + */ +export type SkillManageRequest = Message<"liveagent.gateway.v2.SkillManageRequest"> & { + /** + * @generated from field: string payload_json = 1; + */ + payloadJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SkillManageRequest. + * Use `create(SkillManageRequestSchema)` to create a new message. + */ +export const SkillManageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 104); + +/** + * @generated from message liveagent.gateway.v2.SkillManageResponse + */ +export type SkillManageResponse = Message<"liveagent.gateway.v2.SkillManageResponse"> & { + /** + * @generated from field: string result_json = 1; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.SkillManageResponse. + * Use `create(SkillManageResponseSchema)` to create a new message. + */ +export const SkillManageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 105); + +/** + * @generated from message liveagent.gateway.v2.FileMentionListRequest + */ +export type FileMentionListRequest = Message<"liveagent.gateway.v2.FileMentionListRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: uint32 max_results = 2; + */ + maxResults: number; + + /** + * @generated from field: string query = 3; + */ + query: string; + + /** + * @generated from field: optional bool show_hidden = 4; + */ + showHidden?: boolean | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.FileMentionListRequest. + * Use `create(FileMentionListRequestSchema)` to create a new message. + */ +export const FileMentionListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 106); + +/** + * @generated from message liveagent.gateway.v2.FileMentionEntry + */ +export type FileMentionEntry = Message<"liveagent.gateway.v2.FileMentionEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: bool hidden = 3; + */ + hidden: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.FileMentionEntry. + * Use `create(FileMentionEntrySchema)` to create a new message. + */ +export const FileMentionEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 107); + +/** + * @generated from message liveagent.gateway.v2.FileMentionListResponse + */ +export type FileMentionListResponse = Message<"liveagent.gateway.v2.FileMentionListResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.FileMentionEntry entries = 1; + */ + entries: FileMentionEntry[]; + + /** + * @generated from field: bool truncated = 2; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.FileMentionListResponse. + * Use `create(FileMentionListResponseSchema)` to create a new message. + */ +export const FileMentionListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 108); + +/** + * @generated from message liveagent.gateway.v2.FsRoot + */ +export type FsRoot = Message<"liveagent.gateway.v2.FsRoot"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: string kind = 3; + */ + kind: string; + + /** + * @generated from field: string label = 4; + */ + label: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsRoot. + * Use `create(FsRootSchema)` to create a new message. + */ +export const FsRootSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 109); + +/** + * @generated from message liveagent.gateway.v2.FsRootsRequest + */ +export type FsRootsRequest = Message<"liveagent.gateway.v2.FsRootsRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.FsRootsRequest. + * Use `create(FsRootsRequestSchema)` to create a new message. + */ +export const FsRootsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 110); + +/** + * @generated from message liveagent.gateway.v2.FsRootsResponse + */ +export type FsRootsResponse = Message<"liveagent.gateway.v2.FsRootsResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.FsRoot roots = 1; + */ + roots: FsRoot[]; +}; + +/** + * Describes the message liveagent.gateway.v2.FsRootsResponse. + * Use `create(FsRootsResponseSchema)` to create a new message. + */ +export const FsRootsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 111); + +/** + * @generated from message liveagent.gateway.v2.FsListDirsRequest + */ +export type FsListDirsRequest = Message<"liveagent.gateway.v2.FsListDirsRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: uint32 max_results = 2; + */ + maxResults: number; +}; + +/** + * Describes the message liveagent.gateway.v2.FsListDirsRequest. + * Use `create(FsListDirsRequestSchema)` to create a new message. + */ +export const FsListDirsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 112); + +/** + * @generated from message liveagent.gateway.v2.FsDirEntry + */ +export type FsDirEntry = Message<"liveagent.gateway.v2.FsDirEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsDirEntry. + * Use `create(FsDirEntrySchema)` to create a new message. + */ +export const FsDirEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 113); + +/** + * @generated from message liveagent.gateway.v2.FsListDirsResponse + */ +export type FsListDirsResponse = Message<"liveagent.gateway.v2.FsListDirsResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: repeated liveagent.gateway.v2.FsDirEntry entries = 2; + */ + entries: FsDirEntry[]; + + /** + * @generated from field: bool truncated = 3; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.FsListDirsResponse. + * Use `create(FsListDirsResponseSchema)` to create a new message. + */ +export const FsListDirsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 114); + +/** + * @generated from message liveagent.gateway.v2.FsCreateProjectFolderRequest + */ +export type FsCreateProjectFolderRequest = Message<"liveagent.gateway.v2.FsCreateProjectFolderRequest"> & { + /** + * @generated from field: string parent = 1; + */ + parent: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsCreateProjectFolderRequest. + * Use `create(FsCreateProjectFolderRequestSchema)` to create a new message. + */ +export const FsCreateProjectFolderRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 115); + +/** + * @generated from message liveagent.gateway.v2.FsCreateProjectFolderResponse + */ +export type FsCreateProjectFolderResponse = Message<"liveagent.gateway.v2.FsCreateProjectFolderResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsCreateProjectFolderResponse. + * Use `create(FsCreateProjectFolderResponseSchema)` to create a new message. + */ +export const FsCreateProjectFolderResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 116); + +/** + * @generated from message liveagent.gateway.v2.FsListRequest + */ +export type FsListRequest = Message<"liveagent.gateway.v2.FsListRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: uint32 depth = 3; + */ + depth: number; + + /** + * @generated from field: uint32 offset = 4; + */ + offset: number; + + /** + * @generated from field: uint32 max_results = 5; + */ + maxResults: number; + + /** + * @generated from field: optional bool show_hidden = 6; + */ + showHidden?: boolean | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.FsListRequest. + * Use `create(FsListRequestSchema)` to create a new message. + */ +export const FsListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 117); + +/** + * @generated from message liveagent.gateway.v2.FsListEntry + */ +export type FsListEntry = Message<"liveagent.gateway.v2.FsListEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: bool hidden = 3; + */ + hidden: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.FsListEntry. + * Use `create(FsListEntrySchema)` to create a new message. + */ +export const FsListEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 118); + +/** + * @generated from message liveagent.gateway.v2.FsListResponse + */ +export type FsListResponse = Message<"liveagent.gateway.v2.FsListResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: bool has_path = 2; + */ + hasPath: boolean; + + /** + * @generated from field: uint32 depth = 3; + */ + depth: number; + + /** + * @generated from field: uint32 offset = 4; + */ + offset: number; + + /** + * @generated from field: uint32 max_results = 5; + */ + maxResults: number; + + /** + * @generated from field: uint32 total = 6; + */ + total: number; + + /** + * @generated from field: bool has_more = 7; + */ + hasMore: boolean; + + /** + * @generated from field: repeated liveagent.gateway.v2.FsListEntry entries = 8; + */ + entries: FsListEntry[]; +}; + +/** + * Describes the message liveagent.gateway.v2.FsListResponse. + * Use `create(FsListResponseSchema)` to create a new message. + */ +export const FsListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 119); + +/** + * @generated from message liveagent.gateway.v2.FsReadEditableTextRequest + */ +export type FsReadEditableTextRequest = Message<"liveagent.gateway.v2.FsReadEditableTextRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsReadEditableTextRequest. + * Use `create(FsReadEditableTextRequestSchema)` to create a new message. + */ +export const FsReadEditableTextRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 120); + +/** + * @generated from message liveagent.gateway.v2.FsReadEditableTextResponse + */ +export type FsReadEditableTextResponse = Message<"liveagent.gateway.v2.FsReadEditableTextResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * @generated from field: uint64 mtime_ms = 3; + */ + mtimeMs: bigint; + + /** + * @generated from field: string content_hash = 4; + */ + contentHash: string; + + /** + * @generated from field: uint64 size_bytes = 5; + */ + sizeBytes: bigint; + + /** + * @generated from field: uint64 total_lines = 6; + */ + totalLines: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.FsReadEditableTextResponse. + * Use `create(FsReadEditableTextResponseSchema)` to create a new message. + */ +export const FsReadEditableTextResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 121); + +/** + * @generated from message liveagent.gateway.v2.FsReadWorkspaceImageRequest + */ +export type FsReadWorkspaceImageRequest = Message<"liveagent.gateway.v2.FsReadWorkspaceImageRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsReadWorkspaceImageRequest. + * Use `create(FsReadWorkspaceImageRequestSchema)` to create a new message. + */ +export const FsReadWorkspaceImageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 122); + +/** + * @generated from message liveagent.gateway.v2.FsReadWorkspaceImageResponse + */ +export type FsReadWorkspaceImageResponse = Message<"liveagent.gateway.v2.FsReadWorkspaceImageResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string mime_type = 2; + */ + mimeType: string; + + /** + * @generated from field: string data = 3; + */ + data: string; + + /** + * @generated from field: uint64 size_bytes = 4; + */ + sizeBytes: bigint; + + /** + * @generated from field: uint64 mtime_ms = 5; + */ + mtimeMs: bigint; + + /** + * @generated from field: string content_hash = 6; + */ + contentHash: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsReadWorkspaceImageResponse. + * Use `create(FsReadWorkspaceImageResponseSchema)` to create a new message. + */ +export const FsReadWorkspaceImageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 123); + +/** + * @generated from message liveagent.gateway.v2.ChatFileOpenRequest + */ +export type ChatFileOpenRequest = Message<"liveagent.gateway.v2.ChatFileOpenRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string workdir = 2; + */ + workdir: string; + + /** + * @generated from field: string path = 3; + */ + path: string; + + /** + * @generated from field: string source = 4; + */ + source: string; + + /** + * @generated from field: optional uint32 line = 5; + */ + line?: number | undefined; + + /** + * @generated from field: optional uint32 end_line = 6; + */ + endLine?: number | undefined; + + /** + * @generated from field: optional uint32 column = 7; + */ + column?: number | undefined; + + /** + * @generated from field: bool open_in_file_manager = 8; + */ + openInFileManager: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatFileOpenRequest. + * Use `create(ChatFileOpenRequestSchema)` to create a new message. + */ +export const ChatFileOpenRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 124); + +/** + * @generated from message liveagent.gateway.v2.ChatFileOpenResponse + */ +export type ChatFileOpenResponse = Message<"liveagent.gateway.v2.ChatFileOpenResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: string workdir = 3; + */ + workdir: string; + + /** + * @generated from field: string path = 4; + */ + path: string; + + /** + * @generated from field: optional uint32 line = 5; + */ + line?: number | undefined; + + /** + * @generated from field: optional uint32 end_line = 6; + */ + endLine?: number | undefined; + + /** + * @generated from field: optional uint32 column = 7; + */ + column?: number | undefined; + + /** + * @generated from field: bool outside_workspace = 8; + */ + outsideWorkspace: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatFileOpenResponse. + * Use `create(ChatFileOpenResponseSchema)` to create a new message. + */ +export const ChatFileOpenResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 125); + +/** + * @generated from message liveagent.gateway.v2.FsWriteTextRequest + */ +export type FsWriteTextRequest = Message<"liveagent.gateway.v2.FsWriteTextRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: string content = 3; + */ + content: string; + + /** + * @generated from field: string mode = 4; + */ + mode: string; + + /** + * @generated from field: uint64 expected_mtime_ms = 5; + */ + expectedMtimeMs: bigint; + + /** + * @generated from field: string expected_content_hash = 6; + */ + expectedContentHash: string; + + /** + * @generated from field: bool has_expected_mtime_ms = 7; + */ + hasExpectedMtimeMs: boolean; + + /** + * @generated from field: bool has_expected_content_hash = 8; + */ + hasExpectedContentHash: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.FsWriteTextRequest. + * Use `create(FsWriteTextRequestSchema)` to create a new message. + */ +export const FsWriteTextRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 126); + +/** + * @generated from message liveagent.gateway.v2.FsWriteTextResponse + */ +export type FsWriteTextResponse = Message<"liveagent.gateway.v2.FsWriteTextResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string mode = 2; + */ + mode: string; + + /** + * @generated from field: bool existed_before = 3; + */ + existedBefore: boolean; + + /** + * @generated from field: uint64 bytes_written = 4; + */ + bytesWritten: bigint; + + /** + * @generated from field: uint64 mtime_ms = 5; + */ + mtimeMs: bigint; + + /** + * @generated from field: string content_hash = 6; + */ + contentHash: string; + + /** + * @generated from field: uint64 total_lines = 7; + */ + totalLines: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.FsWriteTextResponse. + * Use `create(FsWriteTextResponseSchema)` to create a new message. + */ +export const FsWriteTextResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 127); + +/** + * @generated from message liveagent.gateway.v2.FsCreateDirRequest + */ +export type FsCreateDirRequest = Message<"liveagent.gateway.v2.FsCreateDirRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsCreateDirRequest. + * Use `create(FsCreateDirRequestSchema)` to create a new message. + */ +export const FsCreateDirRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 128); + +/** + * @generated from message liveagent.gateway.v2.FsCreateDirResponse + */ +export type FsCreateDirResponse = Message<"liveagent.gateway.v2.FsCreateDirResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsCreateDirResponse. + * Use `create(FsCreateDirResponseSchema)` to create a new message. + */ +export const FsCreateDirResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 129); + +/** + * @generated from message liveagent.gateway.v2.FsRenameRequest + */ +export type FsRenameRequest = Message<"liveagent.gateway.v2.FsRenameRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string from_path = 2; + */ + fromPath: string; + + /** + * @generated from field: string to_path = 3; + */ + toPath: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsRenameRequest. + * Use `create(FsRenameRequestSchema)` to create a new message. + */ +export const FsRenameRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 130); + +/** + * @generated from message liveagent.gateway.v2.FsRenameResponse + */ +export type FsRenameResponse = Message<"liveagent.gateway.v2.FsRenameResponse"> & { + /** + * @generated from field: string from_path = 1; + */ + fromPath: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: string kind = 3; + */ + kind: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsRenameResponse. + * Use `create(FsRenameResponseSchema)` to create a new message. + */ +export const FsRenameResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 131); + +/** + * @generated from message liveagent.gateway.v2.FsDeleteRequest + */ +export type FsDeleteRequest = Message<"liveagent.gateway.v2.FsDeleteRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsDeleteRequest. + * Use `create(FsDeleteRequestSchema)` to create a new message. + */ +export const FsDeleteRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 132); + +/** + * @generated from message liveagent.gateway.v2.FsDeleteResponse + */ +export type FsDeleteResponse = Message<"liveagent.gateway.v2.FsDeleteResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; +}; + +/** + * Describes the message liveagent.gateway.v2.FsDeleteResponse. + * Use `create(FsDeleteResponseSchema)` to create a new message. + */ +export const FsDeleteResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 133); + +/** + * @generated from message liveagent.gateway.v2.PingRequest + */ +export type PingRequest = Message<"liveagent.gateway.v2.PingRequest"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.PingRequest. + * Use `create(PingRequestSchema)` to create a new message. + */ +export const PingRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 134); + +/** + * @generated from message liveagent.gateway.v2.PongResponse + */ +export type PongResponse = Message<"liveagent.gateway.v2.PongResponse"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.PongResponse. + * Use `create(PongResponseSchema)` to create a new message. + */ +export const PongResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 135); + +/** + * @generated from message liveagent.gateway.v2.ErrorResponse + */ +export type ErrorResponse = Message<"liveagent.gateway.v2.ErrorResponse"> & { + /** + * @generated from field: int32 code = 1; + */ + code: number; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ErrorResponse. + * Use `create(ErrorResponseSchema)` to create a new message. + */ +export const ErrorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 136); + +/** + * @generated from message liveagent.gateway.v2.ProviderModelsRequest + */ +export type ProviderModelsRequest = Message<"liveagent.gateway.v2.ProviderModelsRequest"> & { + /** + * @generated from field: string provider_type = 1; + */ + providerType: string; + + /** + * @generated from field: string base_url = 2; + */ + baseUrl: string; + + /** + * @generated from field: string api_key = 3; + */ + apiKey: string; + + /** + * @generated from field: bool use_system_proxy = 4; + */ + useSystemProxy: boolean; + + /** + * 可选的模型列表完整地址;非空时跳过基于 base_url 的端点推导。 + * + * @generated from field: string models_url = 5; + */ + modelsUrl: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ProviderModelsRequest. + * Use `create(ProviderModelsRequestSchema)` to create a new message. + */ +export const ProviderModelsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 137); + +/** + * @generated from message liveagent.gateway.v2.ProviderModelsResponse + */ +export type ProviderModelsResponse = Message<"liveagent.gateway.v2.ProviderModelsResponse"> & { + /** + * @generated from field: string models_json = 1; + */ + modelsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ProviderModelsResponse. + * Use `create(ProviderModelsResponseSchema)` to create a new message. + */ +export const ProviderModelsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 138); + +/** + * @generated from message liveagent.gateway.v2.ProviderUsageRequest + */ +export type ProviderUsageRequest = Message<"liveagent.gateway.v2.ProviderUsageRequest"> & { + /** + * @generated from field: string provider_id = 1; + */ + providerId: string; + + /** + * @generated from field: bool refresh = 2; + */ + refresh: boolean; + + /** + * 非空时为「按草稿测试」:桌面端按此 JSON 配置(UsageQueryConfig 形状)执行 + * 一次查询——忽略启用开关、不落库、不读写缓存;空串为常规查询。 + * + * @generated from field: string config_json = 3; + */ + configJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ProviderUsageRequest. + * Use `create(ProviderUsageRequestSchema)` to create a new message. + */ +export const ProviderUsageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 139); + +/** + * @generated from message liveagent.gateway.v2.ProviderUsageResponse + */ +export type ProviderUsageResponse = Message<"liveagent.gateway.v2.ProviderUsageResponse"> & { + /** + * @generated from field: string result_json = 1; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ProviderUsageResponse. + * Use `create(ProviderUsageResponseSchema)` to create a new message. + */ +export const ProviderUsageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 140); + +/** + * ChatIngressBatch carries contiguous logical records for one run. Each record + * occupies exactly one sequence number beginning at first_seq. + * + * @generated from message liveagent.gateway.v2.ChatIngressBatch + */ +export type ChatIngressBatch = Message<"liveagent.gateway.v2.ChatIngressBatch"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: uint64 first_seq = 3; + */ + firstSeq: bigint; + + /** + * @generated from field: repeated liveagent.gateway.v2.ChatIngressRecord records = 4; + */ + records: ChatIngressRecord[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressBatch. + * Use `create(ChatIngressBatchSchema)` to create a new message. + */ +export const ChatIngressBatchSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 141); + +/** + * @generated from message liveagent.gateway.v2.ChatIngressRecord + */ +export type ChatIngressRecord = Message<"liveagent.gateway.v2.ChatIngressRecord"> & { + /** + * @generated from oneof liveagent.gateway.v2.ChatIngressRecord.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressDelta delta = 1; + */ + value: ChatIngressDelta; + case: "delta"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressCheckpoint checkpoint = 2; + */ + value: ChatIngressCheckpoint; + case: "checkpoint"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressTerminal terminal = 3; + */ + value: ChatIngressTerminal; + case: "terminal"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatIngressHeartbeat heartbeat = 4; + */ + value: ChatIngressHeartbeat; + case: "heartbeat"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressRecord. + * Use `create(ChatIngressRecordSchema)` to create a new message. + */ +export const ChatIngressRecordSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 142); + +/** + * @generated from message liveagent.gateway.v2.ChatIngressDelta + */ +export type ChatIngressDelta = Message<"liveagent.gateway.v2.ChatIngressDelta"> & { + /** + * @generated from field: string event_json = 1; + */ + eventJson: string; + + /** + * @generated from field: string worker_id = 2; + */ + workerId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressDelta. + * Use `create(ChatIngressDeltaSchema)` to create a new message. + */ +export const ChatIngressDeltaSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 143); + +/** + * @generated from message liveagent.gateway.v2.ChatIngressHeartbeat + */ +export type ChatIngressHeartbeat = Message<"liveagent.gateway.v2.ChatIngressHeartbeat"> & { + /** + * Unix milliseconds. + * + * @generated from field: int64 updated_at = 1; + */ + updatedAt: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressHeartbeat. + * Use `create(ChatIngressHeartbeatSchema)` to create a new message. + */ +export const ChatIngressHeartbeatSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 144); + +/** + * @generated from message liveagent.gateway.v2.ChatIngressCheckpoint + */ +export type ChatIngressCheckpoint = Message<"liveagent.gateway.v2.ChatIngressCheckpoint"> & { + /** + * @generated from field: uint64 covers_through_seq = 1; + */ + coversThroughSeq: bigint; + + /** + * @generated from field: uint64 revision = 2; + */ + revision: bigint; + + /** + * @generated from field: bytes compressed_projection = 3; + */ + compressedProjection: Uint8Array; + + /** + * @generated from field: uint64 uncompressed_bytes = 4; + */ + uncompressedBytes: bigint; + + /** + * @generated from field: string sha256 = 5; + */ + sha256: string; + + /** + * @generated from field: bool content_complete = 6; + */ + contentComplete: boolean; + + /** + * @generated from field: bool history_required = 7; + */ + historyRequired: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressCheckpoint. + * Use `create(ChatIngressCheckpointSchema)` to create a new message. + */ +export const ChatIngressCheckpointSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 145); + +/** + * @generated from message liveagent.gateway.v2.ChatIngressTerminal + */ +export type ChatIngressTerminal = Message<"liveagent.gateway.v2.ChatIngressTerminal"> & { + /** + * @generated from field: uint64 covers_through_seq = 1; + */ + coversThroughSeq: bigint; + + /** + * @generated from field: uint64 revision = 2; + */ + revision: bigint; + + /** + * @generated from field: bytes compressed_projection = 3; + */ + compressedProjection: Uint8Array; + + /** + * @generated from field: uint64 uncompressed_bytes = 4; + */ + uncompressedBytes: bigint; + + /** + * @generated from field: string sha256 = 5; + */ + sha256: string; + + /** + * @generated from field: bool content_complete = 6; + */ + contentComplete: boolean; + + /** + * @generated from field: bool history_required = 7; + */ + historyRequired: boolean; + + /** + * @generated from field: string state = 8; + */ + state: string; + + /** + * @generated from field: string error_code = 9; + */ + errorCode: string; + + /** + * @generated from field: string error_message = 10; + */ + errorMessage: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressTerminal. + * Use `create(ChatIngressTerminalSchema)` to create a new message. + */ +export const ChatIngressTerminalSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 146); + +/** + * ChatIngressResume declares the replay window retained by the desktop after a + * reconnect. The gateway answers each run with ChatIngressAck. + * + * @generated from message liveagent.gateway.v2.ChatIngressResume + */ +export type ChatIngressResume = Message<"liveagent.gateway.v2.ChatIngressResume"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.ChatIngressRunResume runs = 1; + */ + runs: ChatIngressRunResume[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressResume. + * Use `create(ChatIngressResumeSchema)` to create a new message. + */ +export const ChatIngressResumeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 147); + +/** + * @generated from message liveagent.gateway.v2.ChatIngressRunResume + */ +export type ChatIngressRunResume = Message<"liveagent.gateway.v2.ChatIngressRunResume"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: uint64 replay_from_seq = 3; + */ + replayFromSeq: bigint; + + /** + * @generated from field: uint64 replay_through_seq = 4; + */ + replayThroughSeq: bigint; + + /** + * @generated from field: uint64 next_seq = 5; + */ + nextSeq: bigint; + + /** + * @generated from field: uint64 latest_checkpoint_seq = 6; + */ + latestCheckpointSeq: bigint; + + /** + * @generated from field: uint64 terminal_seq = 7; + */ + terminalSeq: bigint; + + /** + * @generated from field: bool terminal_pending = 8; + */ + terminalPending: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressRunResume. + * Use `create(ChatIngressRunResumeSchema)` to create a new message. + */ +export const ChatIngressRunResumeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 148); + +/** + * ChatIngressFragment transports one encoded ChatIngressRecord that exceeds a + * normal batch frame. Fragments do not consume additional logical sequence + * numbers; source_seq is the sequence of the reconstructed record. + * + * @generated from message liveagent.gateway.v2.ChatIngressFragment + */ +export type ChatIngressFragment = Message<"liveagent.gateway.v2.ChatIngressFragment"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: uint64 source_seq = 3; + */ + sourceSeq: bigint; + + /** + * @generated from field: uint32 fragment_index = 4; + */ + fragmentIndex: number; + + /** + * @generated from field: uint32 fragment_count = 5; + */ + fragmentCount: number; + + /** + * @generated from field: bytes encoded_record_chunk = 6; + */ + encodedRecordChunk: Uint8Array; + + /** + * @generated from field: uint64 encoded_record_bytes = 7; + */ + encodedRecordBytes: bigint; + + /** + * @generated from field: string sha256 = 8; + */ + sha256: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressFragment. + * Use `create(ChatIngressFragmentSchema)` to create a new message. + */ +export const ChatIngressFragmentSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 149); + +/** + * @generated from message liveagent.gateway.v2.ChatIngressAck + */ +export type ChatIngressAck = Message<"liveagent.gateway.v2.ChatIngressAck"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: uint64 committed_through = 3; + */ + committedThrough: bigint; + + /** + * @generated from field: uint64 expected_next = 4; + */ + expectedNext: bigint; + + /** + * @generated from field: liveagent.gateway.v2.ChatIngressAck.Action action = 5; + */ + action: ChatIngressAck_Action; + + /** + * @generated from field: bool terminal_committed = 6; + */ + terminalCommitted: boolean; + + /** + * @generated from field: string error_code = 7; + */ + errorCode: string; + + /** + * @generated from field: string error_message = 8; + */ + errorMessage: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatIngressAck. + * Use `create(ChatIngressAckSchema)` to create a new message. + */ +export const ChatIngressAckSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 150); + +/** + * @generated from enum liveagent.gateway.v2.ChatIngressAck.Action + */ +export enum ChatIngressAck_Action { + /** + * @generated from enum value: ACTION_UNSPECIFIED = 0; + */ + ACTION_UNSPECIFIED = 0, + + /** + * @generated from enum value: CONTINUE = 1; + */ + CONTINUE = 1, + + /** + * @generated from enum value: REPLAY_FROM_EXPECTED = 2; + */ + REPLAY_FROM_EXPECTED = 2, + + /** + * @generated from enum value: SEND_CHECKPOINT = 3; + */ + SEND_CHECKPOINT = 3, + + /** + * @generated from enum value: REJECTED = 4; + */ + REJECTED = 4, +} + +/** + * Describes the enum liveagent.gateway.v2.ChatIngressAck.Action. + */ +export const ChatIngressAck_ActionSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v2_gateway, 150, 0); + +/** + * @generated from message liveagent.gateway.v2.HistorySetCwdRequest + */ +export type HistorySetCwdRequest = Message<"liveagent.gateway.v2.HistorySetCwdRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string cwd = 2; + */ + cwd: string; +}; + +/** + * Describes the message liveagent.gateway.v2.HistorySetCwdRequest. + * Use `create(HistorySetCwdRequestSchema)` to create a new message. + */ +export const HistorySetCwdRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 151); + +/** + * @generated from message liveagent.gateway.v2.HistorySetCwdResponse + */ +export type HistorySetCwdResponse = Message<"liveagent.gateway.v2.HistorySetCwdResponse"> & { + /** + * @generated from field: liveagent.gateway.v2.ConversationSummary conversation = 1; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v2.HistorySetCwdResponse. + * Use `create(HistorySetCwdResponseSchema)` to create a new message. + */ +export const HistorySetCwdResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway, 152); + +/** + * @generated from enum liveagent.gateway.v2.TunnelFrameKind + */ +export enum TunnelFrameKind { + /** + * @generated from enum value: TUNNEL_FRAME_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_REQUEST_START = 1; + */ + HTTP_REQUEST_START = 1, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY = 2; + */ + HTTP_REQUEST_BODY = 2, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_REQUEST_END = 3; + */ + HTTP_REQUEST_END = 3, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_RESPONSE_START = 4; + */ + HTTP_RESPONSE_START = 4, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY = 5; + */ + HTTP_RESPONSE_BODY = 5, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_RESPONSE_END = 6; + */ + HTTP_RESPONSE_END = 6, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_DIAL = 7; + */ + WS_DIAL = 7, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_DIAL_OK = 8; + */ + WS_DIAL_OK = 8, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_DIAL_ERROR = 9; + */ + WS_DIAL_ERROR = 9, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_FRAME = 10; + */ + WS_FRAME = 10, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_CLOSE = 11; + */ + WS_CLOSE = 11, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_ERROR = 12; + */ + ERROR = 12, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_CANCEL = 13; + */ + CANCEL = 13, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_PING = 14; + */ + PING = 14, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_PONG = 15; + */ + PONG = 15, +} + +/** + * Describes the enum liveagent.gateway.v2.TunnelFrameKind. + */ +export const TunnelFrameKindSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v2_gateway, 0); + +/** + * @generated from enum liveagent.gateway.v2.TunnelWsMessageType + */ +export enum TunnelWsMessageType { + /** + * @generated from enum value: TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: TUNNEL_WS_MESSAGE_TYPE_TEXT = 1; + */ + TEXT = 1, + + /** + * @generated from enum value: TUNNEL_WS_MESSAGE_TYPE_BINARY = 2; + */ + BINARY = 2, +} + +/** + * Describes the enum liveagent.gateway.v2.TunnelWsMessageType. + */ +export const TunnelWsMessageTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v2_gateway, 1); + diff --git a/crates/agent-mobile/src/lib/proto/gen/proto/v2/gateway_ws_pb.ts b/crates/agent-mobile/src/lib/proto/gen/proto/v2/gateway_ws_pb.ts new file mode 100644 index 000000000..6b6155610 --- /dev/null +++ b/crates/agent-mobile/src/lib/proto/gen/proto/v2/gateway_ws_pb.ts @@ -0,0 +1,1290 @@ +// v2 统一线协议(WebSocket+Protobuf):/ws/v2(浏览器)、/ws/v2/agent(桌面端)、 +// /ws/v2/terminal(终端数据面)三链路的帧壳。一条 WS 二进制消息承载一条帧,文本帧即协议错误; +// 首帧必须为 hello,鉴权失败以 close code 4401 关闭。业务载荷复用 gateway.proto 的 v2 消息(三端唯一事实源), +// 本文件仅定义帧壳与网关本地载荷。 + +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated from file proto/v2/gateway_ws.proto (package liveagent.gateway.v2, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { AgentEnvelope, ChatCommandRequest, ChatQueueEvent, ErrorResponse, GatewayEnvelope, HistorySyncEvent, ManagedProcessSnapshot, SettingsSyncEvent, SftpEvent, TerminalEvent, TerminalStreamFrame, TunnelStateSnapshot, WorkspaceActivityEvent } from "./gateway_pb"; +import { file_proto_v2_gateway } from "./gateway_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file proto/v2/gateway_ws.proto. + */ +export const file_proto_v2_gateway_ws: GenFile = /*@__PURE__*/ + fileDesc("Chlwcm90by92Mi9nYXRld2F5X3dzLnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MiLSAQoLQ2xpZW50SGVsbG8SGAoQcHJvdG9jb2xfdmVyc2lvbhgBIAEoDRIuCgRyb2xlGAIgASgOMiAubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2xpZW50Um9sZRINCgV0b2tlbhgDIAEoCRIQCghhZ2VudF9pZBgEIAEoCRIVCg1hZ2VudF92ZXJzaW9uGAUgASgJEhMKC2NsaWVudF9uYW1lGAYgASgJEhYKDmNsaWVudF92ZXJzaW9uGAcgASgJEhQKDGNhcGFiaWxpdGllcxgIIAMoCSKmAQoLU2VydmVySGVsbG8SCgoCb2sYASABKAgSDwoHbWVzc2FnZRgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJEhMKC3NlcnZlcl90aW1lGAQgASgDEiAKGGhlYXJ0YmVhdF9wZXJpb2Rfc2Vjb25kcxgFIAEoDRIZChFtYXhfbWVzc2FnZV9ieXRlcxgGIAEoBBIUCgxjYXBhYmlsaXRpZXMYByADKAkiHgoJUGluZ0ZyYW1lEhEKCXRpbWVzdGFtcBgBIAEoAyIeCglQb25nRnJhbWUSEQoJdGltZXN0YW1wGAEgASgDIhcKCUFja1Jlc3VsdBIKCgJvaxgBIAEoCCLiBgoOV2ViQ2xpZW50RnJhbWUSEgoKcmVxdWVzdF9pZBgBIAEoCRIQCghhZ2VudF9pZBgNIAEoCRIyCgVoZWxsbxgCIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNsaWVudEhlbGxvSAASPgoNYWdlbnRfcmVxdWVzdBgDIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkdhdGV3YXlFbnZlbG9wZUgAEjwKCnN0YXR1c19nZXQYBCABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TdGF0dXNHZXRSZXF1ZXN0SAASQAoMY2hhdF9jb21tYW5kGAUgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdENvbW1hbmRSZXF1ZXN0SAASQAoMY2hhdF9wcmVwYXJlGAYgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFByZXBhcmVSZXF1ZXN0SAASRAoOY2hhdF9zdWJzY3JpYmUYByABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0U3Vic2NyaWJlUmVxdWVzdEgAEkgKEGNoYXRfdW5zdWJzY3JpYmUYCCABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0VW5zdWJzY3JpYmVSZXF1ZXN0SAASRgoPY2hhdF9hY3Rpdml0aWVzGAkgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEFjdGl2aXRpZXNSZXF1ZXN0SAASTgoTd29ya3NwYWNlX3N1YnNjcmliZRgKIAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYyLldvcmtzcGFjZVN1YnNjcmliZVJlcXVlc3RIABJSChV3b3Jrc3BhY2VfdW5zdWJzY3JpYmUYCyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VVbnN1YnNjcmliZVJlcXVlc3RIABIvCgRwb25nGAwgASgLMh8ubGl2ZWFnZW50LmdhdGV3YXkudjIuUG9uZ0ZyYW1lSAASPAoKYWdlbnRfbGlzdBgOIAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYyLkFnZW50TGlzdFJlcXVlc3RIAEIJCgdwYXlsb2FkIv8LCg5XZWJTZXJ2ZXJGcmFtZRISCgpyZXF1ZXN0X2lkGAEgASgJEhAKCGFnZW50X2lkGBAgASgJEjIKBWhlbGxvGAIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuU2VydmVySGVsbG9IABI9Cg5hZ2VudF9yZXNwb25zZRgDIAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLkFnZW50RW52ZWxvcGVIABI6Cgtsb2NhbF9lcnJvchgEIAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLkVycm9yUmVzcG9uc2VIABIvCgRwaW5nGAUgASgLMh8ubGl2ZWFnZW50LmdhdGV3YXkudjIuUGluZ0ZyYW1lSAASMwoGc3RhdHVzGAYgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuU3RhdHVzRXZlbnRIABJECg9jaGF0X3N1YnNjcmliZWQYByABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0U3Vic2NyaWJlUmVzdWx0SAASQgoNY2hhdF9hY2NlcHRlZBgIIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRDb21tYW5kQWNjZXB0ZWRIABJFCg9jaGF0X2FjdGl2aXRpZXMYCSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0QWN0aXZpdGllc1Jlc3VsdEgAEjsKCmNoYXRfZXZlbnQYCiABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0U3RyZWFtRXZlbnRIABJGChNjaGF0X2NvbW1hbmRfdXBkYXRlGAsgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdENvbW1hbmRVcGRhdGVIABJOChdjaGF0X3N1YnNjcmlwdGlvbl9yZXNldBgMIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRTdWJzY3JpcHRpb25SZXNldEgAEkAKDWNoYXRfYWN0aXZpdHkYDSABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0QWN0aXZpdHlFdmVudEgAEi4KA2FjaxgOIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLkFja1Jlc3VsdEgAEkAKDmNoYXRfY2FuY2VsbGVkGA8gASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdENhbmNlbFJlc3VsdEgAEjsKCmFnZW50X2xpc3QYESABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5BZ2VudExpc3RSZXN1bHRIABI/Cg1oaXN0b3J5X2V2ZW50GBQgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVN5bmNFdmVudEgAEkEKDnNldHRpbmdzX2V2ZW50GBUgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuU2V0dGluZ3NTeW5jRXZlbnRIABI9Cg50ZXJtaW5hbF9ldmVudBgWIAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsRXZlbnRIABI1CgpzZnRwX2V2ZW50GBcgASgLMh8ubGl2ZWFnZW50LmdhdGV3YXkudjIuU2Z0cEV2ZW50SAASQAoQY2hhdF9xdWV1ZV9ldmVudBgYIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRRdWV1ZUV2ZW50SAASQQoMdHVubmVsX3N0YXRlGBkgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsU3RhdGVTbmFwc2hvdEgAEkUKDXByb2Nlc3Nfc3RhdGUYGiABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NYW5hZ2VkUHJvY2Vzc1NuYXBzaG90SAASSgoSd29ya3NwYWNlX2FjdGl2aXR5GBsgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuV29ya3NwYWNlQWN0aXZpdHlFdmVudEgAQgkKB3BheWxvYWQiEgoQQWdlbnRMaXN0UmVxdWVzdCJECg9BZ2VudExpc3RSZXN1bHQSMQoGYWdlbnRzGAEgAygLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuU3RhdHVzRXZlbnQiigEKEEFnZW50Q2xpZW50RnJhbWUSMgoFaGVsbG8YASABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DbGllbnRIZWxsb0gAEjcKCGVudmVsb3BlGAIgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjIuQWdlbnRFbnZlbG9wZUgAQgkKB3BheWxvYWQijAEKEEFnZW50U2VydmVyRnJhbWUSMgoFaGVsbG8YASABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXJ2ZXJIZWxsb0gAEjkKCGVudmVsb3BlGAIgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuR2F0ZXdheUVudmVsb3BlSABCCQoHcGF5bG9hZCKQAQoTVGVybWluYWxDbGllbnRGcmFtZRIyCgVoZWxsbxgBIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNsaWVudEhlbGxvSAASOgoFZnJhbWUYAiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFN0cmVhbUZyYW1lSABCCQoHcGF5bG9hZCKQAQoTVGVybWluYWxTZXJ2ZXJGcmFtZRIyCgVoZWxsbxgBIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNlcnZlckhlbGxvSAASOgoFZnJhbWUYAiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFN0cmVhbUZyYW1lSABCCQoHcGF5bG9hZCISChBTdGF0dXNHZXRSZXF1ZXN0ItcCCgtTdGF0dXNFdmVudBIOCgZvbmxpbmUYASABKAgSEwoLYWdlbnRfcmVhZHkYAiABKAgSGgoSY2hhdF9ydW50aW1lX3JlYWR5GAMgASgIEhAKCGFnZW50X2lkGAQgASgJEhUKDWFnZW50X3ZlcnNpb24YBSABKAkSEgoKc2Vzc2lvbl9pZBgGIAEoCRIXCg9jb25uZWN0ZWRfc2luY2UYByABKAMSFgoObGFzdF9oZWFydGJlYXQYCCABKAMSFQoNcnVudGltZV9zdGF0ZRgJIAEoCRIeChZydW50aW1lX2xhc3RfaGVhcnRiZWF0GAogASgDEhkKEXJ1bnRpbWVfd29ya2VyX2lkGAsgASgJEhcKD3J1bnRpbWVfdmlzaWJsZRgMIAEoCBIgChhydW50aW1lX2FjdGl2ZV9ydW5fY291bnQYDSABKA0SDAoEbmFtZRgOIAEoCSIkChJDaGF0UHJlcGFyZVJlcXVlc3QSDgoGcmVhc29uGAEgASgJIlgKFENoYXRTdWJzY3JpYmVSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIRCglhZnRlcl9zZXEYAiABKAMSFAoMc3RyZWFtX2Vwb2NoGAMgASgJIusBCg9DaGF0UnVuQWN0aXZpdHkSDgoGcnVuX2lkGAEgASgJEg0KBXN0YXRlGAIgASgJEhMKC3N0YXJ0ZWRfc2VxGAMgASgDEhUKDXVwZGF0ZWRfYXRfbXMYBCABKAMSEwoLdG9vbF9zdGF0dXMYBSABKAkSIQoZdG9vbF9zdGF0dXNfaXNfY29tcGFjdGlvbhgGIAEoCBIZChFjbGllbnRfcmVxdWVzdF9pZBgHIAEoCRIXCg9jb252ZXJzYXRpb25faWQYCCABKAkSDwoHd29ya2RpchgJIAEoCRIQCghhZ2VudF9pZBgKIAEoCSKUAQoPQ2hhdFJ1blNuYXBzaG90Eg4KBnJ1bl9pZBgBIAEoCRIQCghyZXZpc2lvbhgCIAEoAxIUCgxlbnRyaWVzX2pzb24YAyABKAkSEwoLdG9vbF9zdGF0dXMYBCABKAkSIQoZdG9vbF9zdGF0dXNfaXNfY29tcGFjdGlvbhgFIAEoCBIRCglhc19vZl9zZXEYBiABKAMi7gEKE0NoYXRTdWJzY3JpYmVSZXN1bHQSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhQKDHN0cmVhbV9lcG9jaBgCIAEoCRISCgpsYXRlc3Rfc2VxGAMgASgDEg0KBXJlc2V0GAQgASgIEjcKCGFjdGl2aXR5GAUgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFJ1bkFjdGl2aXR5EjcKCHNuYXBzaG90GAYgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFJ1blNuYXBzaG90EhMKC2V2ZW50c19qc29uGAcgAygMIjEKFkNoYXRVbnN1YnNjcmliZVJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIhcKFUNoYXRBY3Rpdml0aWVzUmVxdWVzdCJcChRDaGF0QWN0aXZpdGllc1Jlc3VsdBJEChVydW5uaW5nX2NvbnZlcnNhdGlvbnMYASADKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UnVuQWN0aXZpdHkiTQoPQ2hhdFN0cmVhbUV2ZW50EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRILCgNzZXEYAiABKAMSFAoMcGF5bG9hZF9qc29uGAMgASgMImUKE0NoYXRDb21tYW5kQWNjZXB0ZWQSDgoGcnVuX2lkGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRIUCgxhY2NlcHRlZF9zZXEYAyABKAMSDwoHZGVkdXBlZBgEIAEoCCKLAQoRQ2hhdENvbW1hbmRVcGRhdGUSDgoGcnVuX2lkGAEgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAIgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgDIAEoCRINCgVwaGFzZRgEIAEoCRISCgplcnJvcl9jb2RlGAUgASgJEg8KB21lc3NhZ2UYBiABKAkiMAoVQ2hhdFN1YnNjcmlwdGlvblJlc2V0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSJHChBDaGF0Q2FuY2VsUmVzdWx0EgoKAm9rGAEgASgIEg4KBnJ1bl9pZBgCIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAyABKAkinwEKEUNoYXRBY3Rpdml0eUV2ZW50EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIOCgZydW5faWQYAiABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYAyABKAkSDwoHcnVubmluZxgEIAEoCBINCgVzdGF0ZRgFIAEoCRIPCgd3b3JrZGlyGAYgASgJEhUKDXVwZGF0ZWRfYXRfbXMYByABKAMiLAoZV29ya3NwYWNlU3Vic2NyaWJlUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJIi4KG1dvcmtzcGFjZVVuc3Vic2NyaWJlUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJKlkKCkNsaWVudFJvbGUSGwoXQ0xJRU5UX1JPTEVfVU5TUEVDSUZJRUQQABIXChNDTElFTlRfUk9MRV9CUk9XU0VSEAESFQoRQ0xJRU5UX1JPTEVfQUdFTlQQAkJAWj5naXRodWIuY29tL2xpdmVhZ2VudC9hZ2VudC1nYXRld2F5L2ludGVybmFsL3Byb3RvL3YyO2dhdGV3YXl2MmIGcHJvdG8z", [file_proto_v2_gateway]); + +/** + * ClientHello 是所有 v2 连接的第一帧。 + * + * @generated from message liveagent.gateway.v2.ClientHello + */ +export type ClientHello = Message<"liveagent.gateway.v2.ClientHello"> & { + /** + * 协议版本,当前恒为 2;未知版本被服务端拒绝。 + * + * @generated from field: uint32 protocol_version = 1; + */ + protocolVersion: number; + + /** + * @generated from field: liveagent.gateway.v2.ClientRole role = 2; + */ + role: ClientRole; + + /** + * 网关访问令牌,服务端做常量时间比较。 + * + * @generated from field: string token = 3; + */ + token: string; + + /** + * CLIENT_ROLE_AGENT 必须以 agent_id 声明自身身份;浏览器角色在 + * /ws/v2/terminal 上也必须以 agent_id 显式绑定数据面目标。 + * + * @generated from field: string agent_id = 4; + */ + agentId: string; + + /** + * @generated from field: string agent_version = 5; + */ + agentVersion: string; + + /** + * 客户端标识(如 "webui" / "desktop"),仅用于观测与日志。 + * + * @generated from field: string client_name = 6; + */ + clientName: string; + + /** + * @generated from field: string client_version = 7; + */ + clientVersion: string; + + /** + * Optional feature identifiers supported by this client. Reliable desktop + * chat ingress is negotiated with "CHAT_INGRESS_V1". + * + * @generated from field: repeated string capabilities = 8; + */ + capabilities: string[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ClientHello. + * Use `create(ClientHelloSchema)` to create a new message. + */ +export const ClientHelloSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 0); + +/** + * ServerHello 是服务端对 ClientHello 的应答;ok=false 时随即关闭连接。 + * + * @generated from message liveagent.gateway.v2.ServerHello + */ +export type ServerHello = Message<"liveagent.gateway.v2.ServerHello"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * 仅 agent 角色返回。 + * + * @generated from field: string session_id = 3; + */ + sessionId: string; + + /** + * 服务端 Unix 秒时间戳,供客户端校准。 + * + * @generated from field: int64 server_time = 4; + */ + serverTime: bigint; + + /** + * 服务端心跳周期与消息大小上限,客户端应据此配置本地看门狗与分片。 + * + * @generated from field: uint32 heartbeat_period_seconds = 5; + */ + heartbeatPeriodSeconds: number; + + /** + * @generated from field: uint64 max_message_bytes = 6; + */ + maxMessageBytes: bigint; + + /** + * Feature identifiers supported by the gateway. + * + * @generated from field: repeated string capabilities = 7; + */ + capabilities: string[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ServerHello. + * Use `create(ServerHelloSchema)` to create a new message. + */ +export const ServerHelloSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 1); + +/** + * PingFrame / PongFrame 是应用层心跳:WS 控制帧 ping 探测网络栈,本帧探测页面 JS/事件循环存活。 + * + * @generated from message liveagent.gateway.v2.PingFrame + */ +export type PingFrame = Message<"liveagent.gateway.v2.PingFrame"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.PingFrame. + * Use `create(PingFrameSchema)` to create a new message. + */ +export const PingFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 2); + +/** + * @generated from message liveagent.gateway.v2.PongFrame + */ +export type PongFrame = Message<"liveagent.gateway.v2.PongFrame"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.PongFrame. + * Use `create(PongFrameSchema)` to create a new message. + */ +export const PongFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 3); + +/** + * AckResult 是本地操作的通用确认应答。 + * + * @generated from message liveagent.gateway.v2.AckResult + */ +export type AckResult = Message<"liveagent.gateway.v2.AckResult"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.AckResult. + * Use `create(AckResultSchema)` to create a new message. + */ +export const AckResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 4); + +/** + * WebClientFrame 为浏览器 → 网关方向的帧。除 agent_request 直通臂外,其余臂均为 + * 网关本地操作(由网关自身状态应答,不经桌面端往返)。 + * + * @generated from message liveagent.gateway.v2.WebClientFrame + */ +export type WebClientFrame = Message<"liveagent.gateway.v2.WebClientFrame"> & { + /** + * 请求关联 id,客户端生成、响应帧回携;广播事件与心跳帧为空。 + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * 目标 Agent id。agent_request / status_get / chat_command / chat_prepare / + * workspace_* 必填;hello / pong / agent_list 与全局会话查询臂忽略本字段。 + * + * @generated from field: string agent_id = 13; + */ + agentId: string; + + /** + * @generated from oneof liveagent.gateway.v2.WebClientFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ClientHello hello = 2; + */ + value: ClientHello; + case: "hello"; + } | { + /** + * 直通请求:网关校验白名单与限额后转发桌面端(request_id 按连接命名空间化防冲突)。 + * + * @generated from field: liveagent.gateway.v2.GatewayEnvelope agent_request = 3; + */ + value: GatewayEnvelope; + case: "agentRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v2.StatusGetRequest status_get = 4; + */ + value: StatusGetRequest; + case: "statusGet"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatCommandRequest chat_command = 5; + */ + value: ChatCommandRequest; + case: "chatCommand"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatPrepareRequest chat_prepare = 6; + */ + value: ChatPrepareRequest; + case: "chatPrepare"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatSubscribeRequest chat_subscribe = 7; + */ + value: ChatSubscribeRequest; + case: "chatSubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatUnsubscribeRequest chat_unsubscribe = 8; + */ + value: ChatUnsubscribeRequest; + case: "chatUnsubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatActivitiesRequest chat_activities = 9; + */ + value: ChatActivitiesRequest; + case: "chatActivities"; + } | { + /** + * @generated from field: liveagent.gateway.v2.WorkspaceSubscribeRequest workspace_subscribe = 10; + */ + value: WorkspaceSubscribeRequest; + case: "workspaceSubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.WorkspaceUnsubscribeRequest workspace_unsubscribe = 11; + */ + value: WorkspaceUnsubscribeRequest; + case: "workspaceUnsubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.PongFrame pong = 12; + */ + value: PongFrame; + case: "pong"; + } | { + /** + * Agent 目录查询:全部已登记 Agent(含离线)的状态列表。 + * + * @generated from field: liveagent.gateway.v2.AgentListRequest agent_list = 14; + */ + value: AgentListRequest; + case: "agentList"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.WebClientFrame. + * Use `create(WebClientFrameSchema)` to create a new message. + */ +export const WebClientFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 5); + +/** + * WebServerFrame 为网关 → 浏览器方向的帧。 + * + * @generated from message liveagent.gateway.v2.WebServerFrame + */ +export type WebServerFrame = Message<"liveagent.gateway.v2.WebServerFrame"> & { + /** + * 关联响应回填请求 id;服务端主动推送(广播/心跳)时为空。 + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * 来源/目标 Agent id:目标型响应回填请求声明的目标,Agent 事件广播标注来源; + * hello / ping / ack / agent_list 与全局聚合帧为空。客户端按此字段过滤 + * 非活跃 Agent 的事件。 + * + * @generated from field: string agent_id = 16; + */ + agentId: string; + + /** + * @generated from oneof liveagent.gateway.v2.WebServerFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ServerHello hello = 2; + */ + value: ServerHello; + case: "hello"; + } | { + /** + * 直通响应:桌面端返回的业务信封(含 error=99 错误臂)。 + * + * @generated from field: liveagent.gateway.v2.AgentEnvelope agent_response = 3; + */ + value: AgentEnvelope; + case: "agentResponse"; + } | { + /** + * 网关本地错误(鉴权、校验、离线等)。 + * + * @generated from field: liveagent.gateway.v2.ErrorResponse local_error = 4; + */ + value: ErrorResponse; + case: "localError"; + } | { + /** + * @generated from field: liveagent.gateway.v2.PingFrame ping = 5; + */ + value: PingFrame; + case: "ping"; + } | { + /** + * status_get / chat_prepare 的响应与 status 广播共用一个状态归一化器。 + * + * @generated from field: liveagent.gateway.v2.StatusEvent status = 6; + */ + value: StatusEvent; + case: "status"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatSubscribeResult chat_subscribed = 7; + */ + value: ChatSubscribeResult; + case: "chatSubscribed"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatCommandAccepted chat_accepted = 8; + */ + value: ChatCommandAccepted; + case: "chatAccepted"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatActivitiesResult chat_activities = 9; + */ + value: ChatActivitiesResult; + case: "chatActivities"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatStreamEvent chat_event = 10; + */ + value: ChatStreamEvent; + case: "chatEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatCommandUpdate chat_command_update = 11; + */ + value: ChatCommandUpdate; + case: "chatCommandUpdate"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatSubscriptionReset chat_subscription_reset = 12; + */ + value: ChatSubscriptionReset; + case: "chatSubscriptionReset"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatActivityEvent chat_activity = 13; + */ + value: ChatActivityEvent; + case: "chatActivity"; + } | { + /** + * @generated from field: liveagent.gateway.v2.AckResult ack = 14; + */ + value: AckResult; + case: "ack"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatCancelResult chat_cancelled = 15; + */ + value: ChatCancelResult; + case: "chatCancelled"; + } | { + /** + * @generated from field: liveagent.gateway.v2.AgentListResult agent_list = 17; + */ + value: AgentListResult; + case: "agentList"; + } | { + /** + * 广播事件:session 层吐出的业务消息零塑形直转。 + * + * @generated from field: liveagent.gateway.v2.HistorySyncEvent history_event = 20; + */ + value: HistorySyncEvent; + case: "historyEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SettingsSyncEvent settings_event = 21; + */ + value: SettingsSyncEvent; + case: "settingsEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TerminalEvent terminal_event = 22; + */ + value: TerminalEvent; + case: "terminalEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.SftpEvent sftp_event = 23; + */ + value: SftpEvent; + case: "sftpEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatQueueEvent chat_queue_event = 24; + */ + value: ChatQueueEvent; + case: "chatQueueEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TunnelStateSnapshot tunnel_state = 25; + */ + value: TunnelStateSnapshot; + case: "tunnelState"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ManagedProcessSnapshot process_state = 26; + */ + value: ManagedProcessSnapshot; + case: "processState"; + } | { + /** + * @generated from field: liveagent.gateway.v2.WorkspaceActivityEvent workspace_activity = 27; + */ + value: WorkspaceActivityEvent; + case: "workspaceActivity"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.WebServerFrame. + * Use `create(WebServerFrameSchema)` to create a new message. + */ +export const WebServerFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 6); + +/** + * AgentListRequest 查询 Agent 目录;响应为 AgentListResult。 + * + * @generated from message liveagent.gateway.v2.AgentListRequest + */ +export type AgentListRequest = Message<"liveagent.gateway.v2.AgentListRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.AgentListRequest. + * Use `create(AgentListRequestSchema)` to create a new message. + */ +export const AgentListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 7); + +/** + * AgentListResult 返回全部已登记 Agent 的状态(含离线项,供目录渲染), + * 复用 StatusEvent 归一化器,按 agent_id 排序。 + * + * @generated from message liveagent.gateway.v2.AgentListResult + */ +export type AgentListResult = Message<"liveagent.gateway.v2.AgentListResult"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.StatusEvent agents = 1; + */ + agents: StatusEvent[]; +}; + +/** + * Describes the message liveagent.gateway.v2.AgentListResult. + * Use `create(AgentListResultSchema)` to create a new message. + */ +export const AgentListResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 8); + +/** + * AgentClientFrame 为桌面端 → 网关方向的帧。 + * + * @generated from message liveagent.gateway.v2.AgentClientFrame + */ +export type AgentClientFrame = Message<"liveagent.gateway.v2.AgentClientFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.AgentClientFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ClientHello hello = 1; + */ + value: ClientHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v2.AgentEnvelope envelope = 2; + */ + value: AgentEnvelope; + case: "envelope"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.AgentClientFrame. + * Use `create(AgentClientFrameSchema)` to create a new message. + */ +export const AgentClientFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 9); + +/** + * AgentServerFrame 为网关 → 桌面端方向的帧。 + * + * @generated from message liveagent.gateway.v2.AgentServerFrame + */ +export type AgentServerFrame = Message<"liveagent.gateway.v2.AgentServerFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.AgentServerFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ServerHello hello = 1; + */ + value: ServerHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v2.GatewayEnvelope envelope = 2; + */ + value: GatewayEnvelope; + case: "envelope"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.AgentServerFrame. + * Use `create(AgentServerFrameSchema)` to create a new message. + */ +export const AgentServerFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 10); + +/** + * TerminalClientFrame 为客户端(浏览器或桌面端)→ 网关方向的帧。 + * + * @generated from message liveagent.gateway.v2.TerminalClientFrame + */ +export type TerminalClientFrame = Message<"liveagent.gateway.v2.TerminalClientFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.TerminalClientFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ClientHello hello = 1; + */ + value: ClientHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TerminalStreamFrame frame = 2; + */ + value: TerminalStreamFrame; + case: "frame"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalClientFrame. + * Use `create(TerminalClientFrameSchema)` to create a new message. + */ +export const TerminalClientFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 11); + +/** + * TerminalServerFrame 为网关 → 客户端方向的帧。 + * + * @generated from message liveagent.gateway.v2.TerminalServerFrame + */ +export type TerminalServerFrame = Message<"liveagent.gateway.v2.TerminalServerFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.TerminalServerFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ServerHello hello = 1; + */ + value: ServerHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v2.TerminalStreamFrame frame = 2; + */ + value: TerminalStreamFrame; + case: "frame"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalServerFrame. + * Use `create(TerminalServerFrameSchema)` to create a new message. + */ +export const TerminalServerFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 12); + +/** + * StatusGetRequest 请求网关侧运行状态快照(操作类型:"status.get")。 + * + * @generated from message liveagent.gateway.v2.StatusGetRequest + */ +export type StatusGetRequest = Message<"liveagent.gateway.v2.StatusGetRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.StatusGetRequest. + * Use `create(StatusGetRequestSchema)` to create a new message. + */ +export const StatusGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 13); + +/** + * StatusEvent 镜像 session.Status 的 JSON 形状(字段一一对应)。 + * + * @generated from message liveagent.gateway.v2.StatusEvent + */ +export type StatusEvent = Message<"liveagent.gateway.v2.StatusEvent"> & { + /** + * @generated from field: bool online = 1; + */ + online: boolean; + + /** + * @generated from field: bool agent_ready = 2; + */ + agentReady: boolean; + + /** + * @generated from field: bool chat_runtime_ready = 3; + */ + chatRuntimeReady: boolean; + + /** + * @generated from field: string agent_id = 4; + */ + agentId: string; + + /** + * @generated from field: string agent_version = 5; + */ + agentVersion: string; + + /** + * @generated from field: string session_id = 6; + */ + sessionId: string; + + /** + * @generated from field: int64 connected_since = 7; + */ + connectedSince: bigint; + + /** + * @generated from field: int64 last_heartbeat = 8; + */ + lastHeartbeat: bigint; + + /** + * @generated from field: string runtime_state = 9; + */ + runtimeState: string; + + /** + * @generated from field: int64 runtime_last_heartbeat = 10; + */ + runtimeLastHeartbeat: bigint; + + /** + * @generated from field: string runtime_worker_id = 11; + */ + runtimeWorkerId: string; + + /** + * @generated from field: bool runtime_visible = 12; + */ + runtimeVisible: boolean; + + /** + * @generated from field: uint32 runtime_active_run_count = 13; + */ + runtimeActiveRunCount: number; + + /** + * 仅 agent_list 目录响应填充;实时状态响应与广播保持为空,避免状态热路径访问数据库。 + * + * @generated from field: string name = 14; + */ + name: string; +}; + +/** + * Describes the message liveagent.gateway.v2.StatusEvent. + * Use `create(StatusEventSchema)` to create a new message. + */ +export const StatusEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 14); + +/** + * ChatPrepareRequest 唤醒/探活桌面端 chat 运行时(操作类型:"chat.prepare");响应为 StatusEvent。 + * + * @generated from message liveagent.gateway.v2.ChatPrepareRequest + */ +export type ChatPrepareRequest = Message<"liveagent.gateway.v2.ChatPrepareRequest"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatPrepareRequest. + * Use `create(ChatPrepareRequestSchema)` to create a new message. + */ +export const ChatPrepareRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 15); + +/** + * ChatSubscribeRequest 订阅会话事件流(操作类型:"chat.subscribe");外层 WebClientFrame.agent_id 必须非空, + * 会话按 (agent_id, conversation_id) 隔离。after_seq + stream_epoch + * 支持断线重放:epoch 不匹配或序号超界时服务端置 reset 并从头回放。 + * + * @generated from message liveagent.gateway.v2.ChatSubscribeRequest + */ +export type ChatSubscribeRequest = Message<"liveagent.gateway.v2.ChatSubscribeRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: int64 after_seq = 2; + */ + afterSeq: bigint; + + /** + * @generated from field: string stream_epoch = 3; + */ + streamEpoch: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatSubscribeRequest. + * Use `create(ChatSubscribeRequestSchema)` to create a new message. + */ +export const ChatSubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 16); + +/** + * ChatRunActivity 镜像 session.RunActivity 的 JSON 形状。 + * + * @generated from message liveagent.gateway.v2.ChatRunActivity + */ +export type ChatRunActivity = Message<"liveagent.gateway.v2.ChatRunActivity"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string state = 2; + */ + state: string; + + /** + * @generated from field: int64 started_seq = 3; + */ + startedSeq: bigint; + + /** + * Unix 毫秒时间戳。 + * + * @generated from field: int64 updated_at_ms = 4; + */ + updatedAtMs: bigint; + + /** + * @generated from field: string tool_status = 5; + */ + toolStatus: string; + + /** + * @generated from field: bool tool_status_is_compaction = 6; + */ + toolStatusIsCompaction: boolean; + + /** + * @generated from field: string client_request_id = 7; + */ + clientRequestId: string; + + /** + * 以下字段仅在 chat_activities 列表中填充(agent_id 标注运行所在 Agent)。 + * + * @generated from field: string conversation_id = 8; + */ + conversationId: string; + + /** + * @generated from field: string workdir = 9; + */ + workdir: string; + + /** + * @generated from field: string agent_id = 10; + */ + agentId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRunActivity. + * Use `create(ChatRunActivitySchema)` to create a new message. + */ +export const ChatRunActivitySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 17); + +/** + * ChatRunSnapshot 镜像 session.RunSnapshot 的 JSON 形状。 + * + * @generated from message liveagent.gateway.v2.ChatRunSnapshot + */ +export type ChatRunSnapshot = Message<"liveagent.gateway.v2.ChatRunSnapshot"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: int64 revision = 2; + */ + revision: bigint; + + /** + * 桌面端渲染快照,内容为动态 JSON(按字符串携带,不建模)。 + * + * @generated from field: string entries_json = 3; + */ + entriesJson: string; + + /** + * @generated from field: string tool_status = 4; + */ + toolStatus: string; + + /** + * @generated from field: bool tool_status_is_compaction = 5; + */ + toolStatusIsCompaction: boolean; + + /** + * @generated from field: int64 as_of_seq = 6; + */ + asOfSeq: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRunSnapshot. + * Use `create(ChatRunSnapshotSchema)` to create a new message. + */ +export const ChatRunSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 18); + +/** + * ChatSubscribeResult 是 chat_subscribe 的响应。 + * + * @generated from message liveagent.gateway.v2.ChatSubscribeResult + */ +export type ChatSubscribeResult = Message<"liveagent.gateway.v2.ChatSubscribeResult"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string stream_epoch = 2; + */ + streamEpoch: string; + + /** + * @generated from field: int64 latest_seq = 3; + */ + latestSeq: bigint; + + /** + * @generated from field: bool reset = 4; + */ + reset: boolean; + + /** + * @generated from field: liveagent.gateway.v2.ChatRunActivity activity = 5; + */ + activity?: ChatRunActivity | undefined; + + /** + * @generated from field: liveagent.gateway.v2.ChatRunSnapshot snapshot = 6; + */ + snapshot?: ChatRunSnapshot | undefined; + + /** + * 回放的事件序列。载荷由 chatwire 塑形为深度动态 JSON,按字节携带、不额外 proto 化 + * (重建模会分叉 chatwire 且对压缩后的二进制帧无实际收益)。 + * + * @generated from field: repeated bytes events_json = 7; + */ + eventsJson: Uint8Array[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatSubscribeResult. + * Use `create(ChatSubscribeResultSchema)` to create a new message. + */ +export const ChatSubscribeResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 19); + +/** + * ChatUnsubscribeRequest 取消订阅(操作类型:"chat.unsubscribe");响应 AckResult。 + * + * @generated from message liveagent.gateway.v2.ChatUnsubscribeRequest + */ +export type ChatUnsubscribeRequest = Message<"liveagent.gateway.v2.ChatUnsubscribeRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatUnsubscribeRequest. + * Use `create(ChatUnsubscribeRequestSchema)` to create a new message. + */ +export const ChatUnsubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 20); + +/** + * ChatActivitiesRequest 查询运行中会话(操作类型:"chat.activities");仅由网关状态应答,桌面端离线时亦可用。 + * + * @generated from message liveagent.gateway.v2.ChatActivitiesRequest + */ +export type ChatActivitiesRequest = Message<"liveagent.gateway.v2.ChatActivitiesRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.ChatActivitiesRequest. + * Use `create(ChatActivitiesRequestSchema)` to create a new message. + */ +export const ChatActivitiesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 21); + +/** + * @generated from message liveagent.gateway.v2.ChatActivitiesResult + */ +export type ChatActivitiesResult = Message<"liveagent.gateway.v2.ChatActivitiesResult"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.ChatRunActivity running_conversations = 1; + */ + runningConversations: ChatRunActivity[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatActivitiesResult. + * Use `create(ChatActivitiesResultSchema)` to create a new message. + */ +export const ChatActivitiesResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 22); + +/** + * ChatStreamEvent 是订阅后推送的单条会话事件。 + * + * @generated from message liveagent.gateway.v2.ChatStreamEvent + */ +export type ChatStreamEvent = Message<"liveagent.gateway.v2.ChatStreamEvent"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * 事件序号(与 payload_json 内的 seq 一致,便于不解析载荷即可去重)。 + * + * @generated from field: int64 seq = 2; + */ + seq: bigint; + + /** + * @generated from field: bytes payload_json = 3; + */ + payloadJson: Uint8Array; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatStreamEvent. + * Use `create(ChatStreamEventSchema)` to create a new message. + */ +export const ChatStreamEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 23); + +/** + * ChatCommandAccepted 是 chat_command 提交被接受的响应(chat_command 的接受应答)。 + * + * @generated from message liveagent.gateway.v2.ChatCommandAccepted + */ +export type ChatCommandAccepted = Message<"liveagent.gateway.v2.ChatCommandAccepted"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: int64 accepted_seq = 3; + */ + acceptedSeq: bigint; + + /** + * @generated from field: bool deduped = 4; + */ + deduped: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatCommandAccepted. + * Use `create(ChatCommandAcceptedSchema)` to create a new message. + */ +export const ChatCommandAcceptedSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 24); + +/** + * ChatCommandUpdate 推送命令的前置阶段结果(bound / queued_in_gui / failed),镜像 session.ChatCommandUpdate。 + * + * @generated from message liveagent.gateway.v2.ChatCommandUpdate + */ +export type ChatCommandUpdate = Message<"liveagent.gateway.v2.ChatCommandUpdate"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string client_request_id = 2; + */ + clientRequestId: string; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; + + /** + * @generated from field: string phase = 4; + */ + phase: string; + + /** + * @generated from field: string error_code = 5; + */ + errorCode: string; + + /** + * @generated from field: string message = 6; + */ + message: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatCommandUpdate. + * Use `create(ChatCommandUpdateSchema)` to create a new message. + */ +export const ChatCommandUpdateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 25); + +/** + * ChatSubscriptionReset 通知客户端某会话流已被限流丢弃,需重新订阅(after_seq 断点续传)。 + * + * @generated from message liveagent.gateway.v2.ChatSubscriptionReset + */ +export type ChatSubscriptionReset = Message<"liveagent.gateway.v2.ChatSubscriptionReset"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatSubscriptionReset. + * Use `create(ChatSubscriptionResetSchema)` to create a new message. + */ +export const ChatSubscriptionResetSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 26); + +/** + * ChatCancelResult 是 chat.cancel 的响应。 + * + * @generated from message liveagent.gateway.v2.ChatCancelResult + */ +export type ChatCancelResult = Message<"liveagent.gateway.v2.ChatCancelResult"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; + + /** + * @generated from field: string run_id = 2; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatCancelResult. + * Use `create(ChatCancelResultSchema)` to create a new message. + */ +export const ChatCancelResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 27); + +/** + * ChatActivityEvent 广播会话活动状态变化,镜像 session.ConversationActivityEvent。 + * + * @generated from message liveagent.gateway.v2.ChatActivityEvent + */ +export type ChatActivityEvent = Message<"liveagent.gateway.v2.ChatActivityEvent"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string run_id = 2; + */ + runId: string; + + /** + * @generated from field: string client_request_id = 3; + */ + clientRequestId: string; + + /** + * @generated from field: bool running = 4; + */ + running: boolean; + + /** + * @generated from field: string state = 5; + */ + state: string; + + /** + * @generated from field: string workdir = 6; + */ + workdir: string; + + /** + * @generated from field: int64 updated_at_ms = 7; + */ + updatedAtMs: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatActivityEvent. + * Use `create(ChatActivityEventSchema)` to create a new message. + */ +export const ChatActivityEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 28); + +/** + * WorkspaceSubscribeRequest 订阅工作区活动(操作类型:"workspace.subscribe");响应 AckResult,事件经 workspace_activity 臂广播。 + * + * @generated from message liveagent.gateway.v2.WorkspaceSubscribeRequest + */ +export type WorkspaceSubscribeRequest = Message<"liveagent.gateway.v2.WorkspaceSubscribeRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; +}; + +/** + * Describes the message liveagent.gateway.v2.WorkspaceSubscribeRequest. + * Use `create(WorkspaceSubscribeRequestSchema)` to create a new message. + */ +export const WorkspaceSubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 29); + +/** + * WorkspaceUnsubscribeRequest 取消订阅;响应 AckResult。 + * + * @generated from message liveagent.gateway.v2.WorkspaceUnsubscribeRequest + */ +export type WorkspaceUnsubscribeRequest = Message<"liveagent.gateway.v2.WorkspaceUnsubscribeRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; +}; + +/** + * Describes the message liveagent.gateway.v2.WorkspaceUnsubscribeRequest. + * Use `create(WorkspaceUnsubscribeRequestSchema)` to create a new message. + */ +export const WorkspaceUnsubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 30); + +/** + * ClientRole 区分 /ws/v2/terminal 上连接的所属端(该链路两端共用一条路径,靠 hello.role 区分)。 + * + * @generated from enum liveagent.gateway.v2.ClientRole + */ +export enum ClientRole { + /** + * @generated from enum value: CLIENT_ROLE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CLIENT_ROLE_BROWSER = 1; + */ + BROWSER = 1, + + /** + * @generated from enum value: CLIENT_ROLE_AGENT = 2; + */ + AGENT = 2, +} + +/** + * Describes the enum liveagent.gateway.v2.ClientRole. + */ +export const ClientRoleSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v2_gateway_ws, 0); + diff --git a/crates/agent-mobile/src/lib/providers/llm.ts b/crates/agent-mobile/src/lib/providers/llm.ts new file mode 100644 index 000000000..a4928162a --- /dev/null +++ b/crates/agent-mobile/src/lib/providers/llm.ts @@ -0,0 +1,9 @@ +import type { SharedModelOption } from "@liveagent/ui/lib/models/modelOptions"; +import type { ProviderId } from "../settings"; + +export type ModelOption = SharedModelOption; +export { parseModelValue, toModelValue } from "@liveagent/ui/lib/models/modelValue"; +export { + assistantMessageToText, + normalizeErrorMessage, +} from "@liveagent/ui/lib/providers/errorMessage"; diff --git a/crates/agent-mobile/src/lib/providers/usageQuery.ts b/crates/agent-mobile/src/lib/providers/usageQuery.ts new file mode 100644 index 000000000..af9735d73 --- /dev/null +++ b/crates/agent-mobile/src/lib/providers/usageQuery.ts @@ -0,0 +1,39 @@ +// 平台传输适配层:WebUI 端用量查询经 Gateway WebSocket 桥接到桌面端执行(provider.usage.query)。 +// 共享的状态归约/协调器/hook 逻辑在 usageQueryCore.ts(两端字节镜像),本文件只放平台差异。 + +import { + type ProviderUsageResult, + type UsageQueryProvider, + useProviderUsageWithQuery, +} from "@liveagent/ui/lib/providers/usageQueryCore"; +import { getGatewayWebSocketClient } from "@/lib/gatewaySocket"; +import { loadToken } from "@/lib/storage"; +import type { UsageQueryConfig } from "../settings"; + +export * from "@liveagent/ui/lib/providers/usageQueryCore"; + +export async function queryProviderUsage( + providerId: string, + refresh: boolean, +): Promise { + return getGatewayWebSocketClient( + loadToken().trim(), + ).providerUsageQuery(providerId, refresh); +} + +/** + * 「测试查询」:按编辑器草稿配置试查询——忽略启用开关、不落库、不进缓存。 + * WebUI 草稿的秘密为脱敏空串,靠 *Configured 标志让桌面端沿用已存密钥。 + */ +export async function testProviderUsage( + providerId: string, + config: UsageQueryConfig, +): Promise { + return getGatewayWebSocketClient( + loadToken().trim(), + ).providerUsageTest(providerId, JSON.stringify(config)); +} + +export function useProviderUsage(providers: readonly UsageQueryProvider[]) { + return useProviderUsageWithQuery(queryProviderUsage, providers); +} diff --git a/crates/agent-mobile/src/lib/settings/index.ts b/crates/agent-mobile/src/lib/settings/index.ts new file mode 100644 index 000000000..1331c2df3 --- /dev/null +++ b/crates/agent-mobile/src/lib/settings/index.ts @@ -0,0 +1 @@ +export * from "@liveagent/ui/lib/settings"; diff --git a/crates/agent-mobile/src/lib/sftp/gatewaySftpClient.ts b/crates/agent-mobile/src/lib/sftp/gatewaySftpClient.ts new file mode 100644 index 000000000..34324d489 --- /dev/null +++ b/crates/agent-mobile/src/lib/sftp/gatewaySftpClient.ts @@ -0,0 +1,31 @@ +import type { SftpClient } from "@liveagent/ui/lib/sftp/types"; +import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; + +export function createGatewaySftpClient(api: GatewayWebSocketClientLike): SftpClient { + return { + list(params) { + return api.sftpList(params); + }, + stat(params) { + return api.sftpStat(params); + }, + mkdir(params) { + return api.sftpMkdir(params); + }, + rename(params) { + return api.sftpRename(params); + }, + delete(params) { + return api.sftpDelete(params); + }, + transfer(params) { + return api.sftpTransfer(params); + }, + cancelTransfer(params) { + return api.sftpCancelTransfer(params); + }, + subscribeTransfers(listener) { + return api.subscribeSftpTransfers(listener); + }, + }; +} diff --git a/crates/agent-mobile/src/lib/sidebar/webSidebarBackend.ts b/crates/agent-mobile/src/lib/sidebar/webSidebarBackend.ts new file mode 100644 index 000000000..207e515f6 --- /dev/null +++ b/crates/agent-mobile/src/lib/sidebar/webSidebarBackend.ts @@ -0,0 +1,342 @@ +// Web-only SidebarBackend adapter over the gateway WebSocket client. NOT +// byte-mirrored: everything gateway-specific (snake_case summaries, epoch +// units, the activity-store bridge) is normalized here so the mirrored store +// never sees a wire shape. +// +// Timestamp units (verified against the Go handler + desktop writers): +// `history.list` / `history.event` / `history.workdirs` pass the desktop +// SQLite `updated_at` values through untouched (Go +// websocket_history_handlers.go + websocket_payloads.go do no conversion; +// the Rust bridge in gateway_bridge.rs is a pass-through of `MAX(updated_at)` +// et al.), and the desktop writes epoch **milliseconds** (`Date.now()` in the +// GUI history layer, `now_ms()` fallbacks in Rust). Legacy rows and any +// second-based producer are still normalized defensively: values below the +// seconds ceiling are multiplied to ms, everything else passes through. +// `chat.activity` / `running_conversations` use `UnixMilli()` explicitly. + +import type { SidebarBackend, SidebarListPage } from "@liveagent/ui/lib/sidebar/backend"; +import type { + SidebarBackendEvent, + SidebarConversation, + SidebarScope, +} from "@liveagent/ui/lib/sidebar/types"; +import type { ActivityStore } from "@/lib/chat/stream/activityStore"; +import { formatConversationTitle } from "@/lib/chatUi"; +import type { + AgentStatus, + ConversationSummary, + GatewayHistoryEvent, + HistoryList, + HistoryListFilter, + HistoryWorkdirsResponse, +} from "@/lib/gatewayTypes"; + +// Epoch values below this are seconds (up to year 2286); at or above, they +// are already milliseconds. +const SECONDS_EPOCH_CEILING = 10_000_000_000; + +export function normalizeGatewayEpochMs(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return 0; + } + return value < SECONDS_EPOCH_CEILING ? value * 1000 : value; +} + +// snake_case gateway ConversationSummary → SidebarConversation (all ms). +// provider/model intentionally fall back to "" instead of the selected model: +// sidebar rows must not rebuild when the user switches models. +export function normalizeGatewayConversationSummary( + summary: ConversationSummary, +): SidebarConversation { + const pinnedAt = normalizeGatewayEpochMs(summary.pinned_at); + return { + id: summary.id, + title: formatConversationTitle(summary, summary.id), + providerId: summary.provider_id ?? "", + model: summary.model ?? "", + sessionId: summary.session_id?.trim() ? summary.session_id : undefined, + cwd: summary.cwd?.trim() ? summary.cwd : undefined, + selectedModelJson: summary.selected_model_json?.trim() + ? summary.selected_model_json + : undefined, + messageCount: summary.message_count, + createdAt: normalizeGatewayEpochMs(summary.created_at), + updatedAt: normalizeGatewayEpochMs(summary.updated_at), + isPinned: summary.is_pinned === true, + pinnedAt: summary.is_pinned === true && pinnedAt > 0 ? pinnedAt : null, + isShared: summary.is_shared === true, + }; +} + +// history.list `running_conversations` items → activity-store hydration shape. +export function normalizeRunningConversationItems(items: readonly unknown[] | undefined) { + const normalized: Array<{ + conversationId: string; + runId: string; + state?: string; + workdir?: string | null; + updatedAt?: number; + }> = []; + for (const value of items ?? []) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const source = value as Record; + const conversationId = + typeof source.conversation_id === "string" ? source.conversation_id.trim() : ""; + const runId = typeof source.run_id === "string" ? source.run_id.trim() : ""; + if (!conversationId || !runId) { + continue; + } + normalized.push({ + conversationId, + runId, + state: typeof source.state === "string" ? source.state : undefined, + workdir: typeof source.cwd === "string" ? source.cwd : null, + updatedAt: + typeof source.updated_at === "number" && Number.isFinite(source.updated_at) + ? source.updated_at + : undefined, + }); + } + return normalized; +} + +function scopeToHistoryListFilter(scope: SidebarScope): HistoryListFilter | null { + switch (scope.kind) { + case "workdir": { + const cwd = scope.cwd.trim(); + return cwd ? { cwd } : null; + } + case "unscoped": + return { cwdEmpty: true }; + case "none": + return null; + } +} + +function agentConnectionIdentity(status: AgentStatus | null) { + if (status?.online !== true) { + return ""; + } + const sessionId = status.session_id?.trim() ?? ""; + if (sessionId) { + return `session:${sessionId}`; + } + const connectedSince = status.connected_since; + return typeof connectedSince === "number" && Number.isFinite(connectedSince) + ? `connected:${connectedSince}` + : ""; +} + +type WebSidebarApi = { + listHistory(page: number, pageSize: number, filter?: HistoryListFilter): Promise; + listHistoryWorkdirs(): Promise; + renameHistory(conversationId: string, title: string): Promise; + pinHistory(conversationId: string, isPinned: boolean): Promise; + setHistoryCwd(conversationId: string, cwd: string): Promise; + deleteHistory(conversationId: string): Promise; + subscribeHistory(listener: (event: GatewayHistoryEvent) => void): () => void; + subscribeConnection(listener: (connected: boolean) => void): () => void; + subscribeStatus(listener: (status: AgentStatus | null, error: string | null) => void): () => void; +}; + +export type WebSidebarBackendDeps = { + api: WebSidebarApi; + // App-wide running authority (the dots). The adapter hydrates it from + // history.list responses and bridges its diffs into store events. + activityStore: ActivityStore; + getProtectedConversationIds: () => Iterable; + getActivityKeepConversationIds?: () => ReadonlySet; +}; + +export function createWebSidebarBackend(deps: WebSidebarBackendDeps): SidebarBackend { + const { api, activityStore } = deps; + + return { + async listConversations(page, pageSize, scope): Promise { + const filter = scopeToHistoryListFilter(scope); + if (!filter) { + return { items: [], totalCount: 0 }; + } + const response = await api.listHistory(page, pageSize, filter); + // Authoritative running snapshot rides along with every page; keep the + // activity store in sync exactly like the old reloadHistory did. + activityStore.hydrate(normalizeRunningConversationItems(response.running_conversations), { + keepConversationIds: deps.getActivityKeepConversationIds?.(), + }); + return { + items: response.conversations.map(normalizeGatewayConversationSummary), + totalCount: Math.max(0, response.total_count), + }; + }, + + async listWorkdirs() { + const response = await api.listHistoryWorkdirs(); + return response.workdirs.map((item) => ({ + path: item.path, + conversationCount: item.conversationCount, + updatedAt: normalizeGatewayEpochMs(item.updatedAt), + })); + }, + + async renameConversation(id, title) { + return normalizeGatewayConversationSummary(await api.renameHistory(id, title)); + }, + + async setConversationPinned(id, isPinned) { + return normalizeGatewayConversationSummary(await api.pinHistory(id, isPinned)); + }, + + async setConversationCwd(id, cwd) { + return normalizeGatewayConversationSummary(await api.setHistoryCwd(id, cwd)); + }, + + async deleteConversation(id) { + await api.deleteHistory(id); + }, + + // The single event subscription: gateway history.event upserts/deletes + // plus a diff bridge over the activity store (running/idle transitions). + subscribeEvents(listener: (event: SidebarBackendEvent) => void) { + const unsubscribeHistory = api.subscribeHistory((event) => { + const conversationId = event.conversation_id.trim(); + if (!conversationId) { + return; + } + if (event.kind === "delete") { + listener({ kind: "delete", conversationId }); + return; + } + listener({ + kind: "upsert", + conversationId, + conversation: normalizeGatewayConversationSummary(event.conversation), + }); + }); + + let runningIds = new Set(); + const emitActivityDiff = () => { + const snapshot = activityStore.getSnapshot(); + const next = new Set(); + for (const [conversationId, activity] of snapshot.activities) { + next.add(conversationId); + if (!runningIds.has(conversationId)) { + listener({ + kind: "running", + conversationId, + workdir: activity.workdir, + updatedAt: normalizeGatewayEpochMs(activity.updatedAt) || undefined, + }); + } + } + for (const conversationId of runningIds) { + if (!next.has(conversationId)) { + listener({ kind: "idle", conversationId }); + } + } + runningIds = next; + }; + const unsubscribeActivity = activityStore.subscribe(emitActivityDiff); + // Seed the current running set so a store (re)start sees active runs. + emitActivityDiff(); + + return () => { + unsubscribeHistory(); + unsubscribeActivity(); + }; + }, + + subscribeConnection(listener) { + // For the sidebar, "connected" means the whole read path works: the + // browser⇄gateway socket is authenticated AND the desktop agent behind + // it is online. The agent can drop and return while the socket never + // blips (and after a gateway restart the socket recovers before the + // agent has re-registered), so folding agent online-ness in here makes + // the store's reconnect refetch fire the moment reads can actually + // succeed — clearing any stale listError immediately instead of on the + // next reconcile tick. Both sources replay their current state to late + // subscribers; dedup keeps the store's disconnect latch edge-triggered. + // Agent status is scoped to the socket epoch that delivered it. Keeping + // an old `online=true` across browser-socket reconnect would announce + // readiness as soon as auth succeeds, before the new socket's replayed + // status arrives. Gateway deliberately sends auth response first and + // status.event second, so freshness is an explicit part of the state. + let socketConnected = false; + let agentStatusFresh = false; + let agentOnline = false; + let agentIdentity = ""; + let lastEmitted: boolean | null = null; + const emit = () => { + const next = socketConnected && agentStatusFresh && agentOnline; + if (next === lastEmitted) { + return; + } + lastEmitted = next; + listener(next); + }; + const unsubscribeConnection = api.subscribeConnection((connected) => { + socketConnected = connected === true; + // A status snapshot from the previous socket cannot establish that + // the freshly authenticated read path is ready. + agentStatusFresh = false; + agentOnline = false; + emit(); + }); + const unsubscribeStatus = api.subscribeStatus((status) => { + const nextOnline = status?.online === true; + const nextIdentity = agentConnectionIdentity(status); + const sessionReplaced = + socketConnected && + agentStatusFresh && + agentOnline && + nextOnline && + agentIdentity !== "" && + nextIdentity !== "" && + agentIdentity !== nextIdentity; + + agentStatusFresh = socketConnected; + agentOnline = nextOnline; + if (nextOnline) { + if (nextIdentity) { + agentIdentity = nextIdentity; + } + } else { + agentIdentity = ""; + } + + // Replacing the desktop AgentSession is an online→online transition, + // but it closes every pending unary stream from the previous session. + // Synthesize a reconnect edge so the sidebar invalidates that failed + // generation and re-fetches against the new session. + if (sessionReplaced && lastEmitted === true) { + lastEmitted = false; + listener(false); + } + emit(); + }); + return () => { + unsubscribeConnection(); + unsubscribeStatus(); + }; + }, + + getProtectedConversationIds: () => deps.getProtectedConversationIds(), + }; +} + +// Inert backend for the pre-auth phase (no gateway client yet). The store is +// never started against it; it only has to satisfy the interface. +export function createIdleSidebarBackend(): SidebarBackend { + const notReady = () => Promise.reject(new Error("Gateway client is not ready")); + return { + listConversations: () => Promise.resolve({ items: [], totalCount: 0 }), + listWorkdirs: () => Promise.resolve([]), + renameConversation: notReady, + setConversationPinned: notReady, + setConversationCwd: notReady, + deleteConversation: notReady, + subscribeEvents: () => () => {}, + getProtectedConversationIds: () => [], + }; +} diff --git a/crates/agent-mobile/src/lib/storage.ts b/crates/agent-mobile/src/lib/storage.ts new file mode 100644 index 000000000..0eccfc77e --- /dev/null +++ b/crates/agent-mobile/src/lib/storage.ts @@ -0,0 +1,26 @@ +const TOKEN_KEY = "liveagent.gateway.token"; +const GATEWAY_URL_KEY = "liveagent.gateway.url"; + +export function loadToken(): string { + return window.localStorage.getItem(TOKEN_KEY) ?? ""; +} + +export function saveToken(token: string): void { + window.localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken(): void { + window.localStorage.removeItem(TOKEN_KEY); +} + +export function loadGatewayUrl(): string { + return window.localStorage.getItem(GATEWAY_URL_KEY) ?? ""; +} + +export function saveGatewayUrl(url: string): void { + window.localStorage.setItem(GATEWAY_URL_KEY, url); +} + +export function clearGatewayUrl(): void { + window.localStorage.removeItem(GATEWAY_URL_KEY); +} diff --git a/crates/agent-mobile/src/lib/terminal/gatewaySshLocalForwardClient.ts b/crates/agent-mobile/src/lib/terminal/gatewaySshLocalForwardClient.ts new file mode 100644 index 000000000..5ad35599f --- /dev/null +++ b/crates/agent-mobile/src/lib/terminal/gatewaySshLocalForwardClient.ts @@ -0,0 +1,49 @@ +// Web transport for SSH local port forwarding: operations relay through the +// gateway's terminal.ssh_local_forward_* websocket actions to the +// desktop-owned registry, and lifecycle events arrive as terminal events of +// kind "ssh_local_forward". This file is the per-platform adapter — the +// desktop frontend ships its own copy speaking Tauri invoke +// (tauriSshLocalForwardClient.ts). + +import type { + SshLocalForwardClient, + SshLocalForwardEvent, +} from "@liveagent/ui/lib/terminal/sshLocalForwardTypes"; +import { getGatewayWebSocketClient, onGatewayWebSocketClientReplaced } from "@/lib/gatewaySocket"; +import { loadToken } from "@/lib/storage"; + +function client() { + return getGatewayWebSocketClient(loadToken().trim()); +} + +export const gatewaySshLocalForwardClient: SshLocalForwardClient = { + async list(params) { + return client().listSshLocalForwards(params); + }, + async start(params) { + return client().startSshLocalForward(params); + }, + async stop(params) { + return client().stopSshLocalForward(params); + }, + /** Advisory loopback-port probe against the desktop machine; `true` means + * the port looked free there. The authoritative bind still happens in + * `start`, so treat this as UX only. */ + async checkLocalPort(port) { + return client().checkSshLocalForwardPort(port); + }, + async subscribe(listener: (event: SshLocalForwardEvent) => void) { + // Subscriptions live on one client instance; when the singleton is + // replaced (token change) re-attach to the new instance. The panel + // re-lists after subscribing, so missed events converge via revision. + let detach = client().subscribeSshLocalForward(listener); + const detachReplaced = onGatewayWebSocketClientReplaced(() => { + detach(); + detach = client().subscribeSshLocalForward(listener); + }); + return () => { + detach(); + detachReplaced(); + }; + }, +}; diff --git a/crates/agent-mobile/src/lib/terminal/gatewayTerminalClient.ts b/crates/agent-mobile/src/lib/terminal/gatewayTerminalClient.ts new file mode 100644 index 000000000..aa957644b --- /dev/null +++ b/crates/agent-mobile/src/lib/terminal/gatewayTerminalClient.ts @@ -0,0 +1,53 @@ +import type { TerminalClient } from "@liveagent/ui/lib/terminal/types"; +import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; + +export function createGatewayTerminalClient(api: GatewayWebSocketClientLike): TerminalClient { + return { + shellOptions() { + return api.terminalShellOptions(); + }, + list(projectPathKey) { + return api.listTerminals(projectPathKey); + }, + create(params) { + return api.createTerminal(params); + }, + createSsh(params) { + return api.createSshTerminal(params); + }, + answerSshPrompt(params) { + return api.answerSshTerminalPrompt(params); + }, + async cancelSshPrompt(promptId) { + await api.cancelSshTerminalPrompt(promptId); + }, + sshReconnect(sessionId, projectPathKey) { + return api.reconnectSshTerminal(sessionId, projectPathKey); + }, + sshLatency(sessionId, projectPathKey) { + return api.sshTerminalLatency(sessionId, projectPathKey); + }, + listSshTerminalTabs(projectPathKey) { + return api.listSshTerminalTabs(projectPathKey); + }, + openSshTerminalTab(params) { + return api.openSshTerminalTab(params); + }, + closeSshTerminalTab(tabId) { + return api.closeSshTerminalTab(tabId); + }, + rename(sessionId, title, projectPathKey) { + return api.renameTerminal(sessionId, title, projectPathKey); + }, + close(sessionId, projectPathKey) { + return api.closeTerminal(sessionId, projectPathKey); + }, + closeProject(projectPathKey) { + return api.closeProjectTerminals(projectPathKey); + }, + subscribe(listener) { + return api.subscribeTerminal(listener); + }, + stream: api.terminalStream, + }; +} diff --git a/crates/agent-mobile/src/lib/terminal/gatewayTerminalStreamClient.ts b/crates/agent-mobile/src/lib/terminal/gatewayTerminalStreamClient.ts new file mode 100644 index 000000000..279584bc1 --- /dev/null +++ b/crates/agent-mobile/src/lib/terminal/gatewayTerminalStreamClient.ts @@ -0,0 +1,524 @@ +import { normalizeUnknownTerminalSession } from "@liveagent/ui/lib/terminal/normalization"; +import { TerminalStreamBuffer } from "@liveagent/ui/lib/terminal/streamBuffer"; +import type { + TerminalSession, + TerminalStreamChunk, + TerminalStreamClient, + TerminalStreamHandle, + TerminalStreamSnapshot, +} from "@liveagent/ui/lib/terminal/types"; +import type { TerminalWireHeader } from "@/lib/gatewaySocketV2/adapters"; +import { + decodeTerminalServerFrame, + encodeTerminalHelloFrame, + encodeTerminalStreamFrame, + GATEWAY_V2_SUBPROTOCOL, +} from "@/lib/gatewaySocketV2/adapters"; + +const INPUT_RETRY_MS = 25; +const ATTACH_RETRY_MS = 250; + +// 帧头形状沿用旧自定义帧的命名;v2 下由适配层映射到 TerminalStreamFrame。 +type TerminalFrameHeader = TerminalWireHeader; + +type PendingAttach = { + handle: GatewayTerminalStreamHandle; + resolve: (handle: GatewayTerminalStreamHandle) => void; + reject: (reason?: unknown) => void; + timeoutId: ReturnType; + retryTimerId: ReturnType | null; +}; + +function terminalStreamUrl() { + const origin = terminalRuntimeOrigin(); + if (!origin) { + throw new Error("Gateway terminal stream origin is unavailable"); + } + // v2 终端数据面唯一端点。 + const url = new URL(origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = "/ws/v2/terminal"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function terminalRuntimeOrigin() { + const candidates = [ + globalThis.location, + typeof window !== "undefined" ? window.location : undefined, + ]; + for (const location of candidates) { + const origin = location?.origin; + if (typeof origin === "string" && origin.trim() && origin !== "null") { + return origin; + } + const href = location?.href; + if (typeof href === "string" && href.trim()) { + const parsed = new URL(href); + if (parsed.origin && parsed.origin !== "null") { + return parsed.origin; + } + } + } + return ""; +} + +function nextStreamId() { + const random = globalThis.crypto?.randomUUID?.(); + return random + ? `terminal-${random}` + : `terminal-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isRetryableAttachError(message: string) { + const normalized = message.trim().toLowerCase(); + return ( + normalized.includes("desktop agent is offline") || + normalized.includes("terminal stream connection") || + normalized.includes("terminal stream is not connected") + ); +} + +class GatewayTerminalStreamHandle extends TerminalStreamBuffer { + constructor( + private readonly owner: BrowserGatewayTerminalStreamClient, + readonly streamId: string, + readonly maxBytes: number | undefined, + snapshot: TerminalStreamSnapshot, + ) { + super(snapshot, { + initialTransportReady: false, + offlineInputRetryMs: INPUT_RETRY_MS, + offlineResizeRetryMs: 50, + sendInput: (bytes, buffer) => + owner.send( + { + kind: "input", + streamId, + sessionId: buffer.snapshot.session.id, + projectPathKey: buffer.snapshot.session.projectPathKey, + }, + bytes, + ), + sendResize: (resize, buffer) => + owner.send({ + kind: "resize", + streamId, + sessionId: buffer.snapshot.session.id, + projectPathKey: buffer.snapshot.session.projectPathKey, + ...resize, + }), + onInputSendError: (_error, bytes, buffer) => { + buffer.markTransportDown(); + buffer.restoreInput(bytes); + owner.scheduleReconnect(); + }, + onResizeSendError: (_error, resize, buffer) => { + buffer.markTransportDown(); + buffer.retryResize(resize); + owner.scheduleReconnect(); + }, + }); + } + + override dispose() { + if (this.streamDisposed) return; + super.dispose(); + this.owner.detach(this.streamId, this.snapshot.session, this); + } + + replaySnapshot(snapshot: TerminalStreamSnapshot) { + if (this.streamDisposed) return; + const previousSessionId = this.snapshot.session.id; + this.snapshot = snapshot; + this.owner.reindexHandle(this, previousSessionId, snapshot.session.id); + this.markTransportReady(); + if (snapshot.bytes.byteLength > 0) { + this.accept({ + sessionId: snapshot.session.id, + projectPathKey: snapshot.session.projectPathKey, + bytes: snapshot.bytes, + startOffset: snapshot.outputStartOffset, + endOffset: snapshot.outputEndOffset, + }); + } + } +} + +export class BrowserGatewayTerminalStreamClient implements TerminalStreamClient { + private socket: WebSocket | null = null; + private connectPromise: Promise | null = null; + private pending = new Map(); + private handlesBySession = new Map>(); + private handlesByStream = new Map(); + private reconnectTimer: ReturnType | null = null; + private disposed = false; + + // getAgentId 提供当前明确的活跃 Agent,终端数据面在 hello 时绑定该目标。 + constructor( + private readonly token: string, + private readonly getAgentId: () => string, + ) {} + + async attach( + session: TerminalSession, + options?: { maxBytes?: number }, + ): Promise { + const streamId = nextStreamId(); + const streamHandle = new GatewayTerminalStreamHandle(this, streamId, options?.maxBytes, { + session, + bytes: new Uint8Array(), + truncated: false, + outputStartOffset: 0, + outputEndOffset: 0, + }); + this.addHandle(streamHandle); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const pending = this.pending.get(streamId); + if (pending) { + this.failPendingAttach(pending, new Error("Terminal stream attach timed out")); + } + }, 15_000); + const pending = { handle: streamHandle, resolve, reject, timeoutId, retryTimerId: null }; + this.pending.set(streamId, pending); + void this.sendPendingAttach(pending); + }); + } + + async send(header: TerminalFrameHeader, data?: Uint8Array) { + await this.ensureConnected(); + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error("Terminal stream is not connected"); + } + this.socket.send( + encodeTerminalStreamFrame(header, data ? new Uint8Array(data) : new Uint8Array()), + ); + } + + detach(streamId: string, session: TerminalSession, handle: GatewayTerminalStreamHandle) { + this.removeHandle(session.id, handle); + const sessionStillAttached = this.handlesBySession.has(session.id); + if (this.socket?.readyState === WebSocket.OPEN) { + try { + this.socket.send( + encodeTerminalStreamFrame({ + kind: "detach", + streamId, + sessionId: sessionStillAttached ? undefined : session.id, + projectPathKey: sessionStillAttached ? undefined : session.projectPathKey, + }), + ); + } catch { + // The socket may move to CLOSING between the readyState check and send. + } + } + if (this.activeHandles().length === 0) { + this.clearReconnectTimer(); + } + } + + dispose() { + this.disposed = true; + this.clearReconnectTimer(); + for (const pending of this.pending.values()) { + clearTimeout(pending.timeoutId); + if (pending.retryTimerId) clearTimeout(pending.retryTimerId); + pending.reject(new Error("Terminal stream client disposed")); + } + this.pending.clear(); + this.handlesBySession.clear(); + this.handlesByStream.clear(); + this.socket?.close(); + this.socket = null; + } + + scheduleReconnect(delayMs = 250) { + if (this.disposed || this.reconnectTimer || this.activeHandles().length === 0) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.reattachActiveHandles(); + }, delayMs); + } + + private async ensureConnected() { + if (this.disposed) { + throw new Error("Terminal stream client disposed"); + } + if (this.socket?.readyState === WebSocket.OPEN) return; + if (this.connectPromise) return this.connectPromise; + this.connectPromise = new Promise((resolve, reject) => { + let settled = false; + let timeoutId: ReturnType | null = null; + const clearAttemptTimeout = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + clearAttemptTimeout(); + reject(error); + }; + const resolveOnce = (socket: WebSocket) => { + if (settled) return; + settled = true; + clearAttemptTimeout(); + this.socket = socket; + resolve(); + }; + let url: string; + try { + url = terminalStreamUrl(); + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + return; + } + // v2:二进制 protobuf 帧 + 子协议协商 + hello 鉴权握手。 + const socket = new WebSocket(url, GATEWAY_V2_SUBPROTOCOL); + socket.binaryType = "arraybuffer"; + const failAttempt = (error: Error) => { + if (settled) return; + clearAttemptTimeout(); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + try { + socket.close(); + } catch { + // The browser may already have torn the socket down. + } + rejectOnce(error); + }; + timeoutId = setTimeout(() => { + failAttempt(new Error("Terminal stream connection timed out")); + }, 15_000); + socket.onopen = () => { + socket.send(encodeTerminalHelloFrame(this.token, this.getAgentId())); + }; + socket.onmessage = (event) => { + if (typeof event.data === "string") { + // v2 链路无文本帧;忽略。 + return; + } + const decoded = decodeTerminalServerFrame(event.data as ArrayBuffer); + if (!decoded) return; + if (decoded.kind === "hello") { + if (decoded.ok) { + resolveOnce(socket); + } else { + failAttempt(new Error(decoded.message || "Terminal stream auth failed")); + } + return; + } + this.handleStreamFrame(decoded.header, decoded.data); + }; + socket.onerror = () => { + failAttempt(new Error("Terminal stream connection failed")); + }; + socket.onclose = () => { + if (!settled) { + failAttempt(new Error("Terminal stream connection closed")); + return; + } + if (this.socket === socket) { + this.socket = null; + this.handleSocketClosed(); + } + }; + }).finally(() => { + this.connectPromise = null; + }); + return this.connectPromise; + } + + private handleStreamFrame(header: TerminalFrameHeader, data: Uint8Array) { + const kind = header.kind ?? ""; + if (kind === "snapshot") { + this.resolveAttach(header, data); + return; + } + if (kind === "output") { + this.emitOutput(header, data); + return; + } + if (kind === "error") { + this.rejectAttach(header.streamId ?? "", header.error || "Terminal stream failed"); + } + } + + private resolveAttach(header: TerminalFrameHeader, data: Uint8Array) { + const streamId = header.streamId ?? ""; + const pending = this.pending.get(streamId); + const session = normalizeUnknownTerminalSession(header.session); + const snapshot = { + session, + bytes: data, + truncated: header.truncated === true, + outputStartOffset: Number(header.startOffset ?? 0), + outputEndOffset: Number(header.endOffset ?? 0), + }; + if (!pending) { + const handle = this.handlesByStream.get(streamId); + handle?.replaySnapshot(snapshot); + handle?.resendCurrentResize(); + return; + } + clearTimeout(pending.timeoutId); + if (pending.retryTimerId) clearTimeout(pending.retryTimerId); + this.pending.delete(streamId); + const previousSessionId = pending.handle.snapshot.session.id; + pending.handle.snapshot = snapshot; + this.reindexHandle(pending.handle, previousSessionId, session.id); + pending.handle.markTransportReady(); + pending.resolve(pending.handle); + } + + private rejectAttach(streamId: string, message: string) { + const pending = this.pending.get(streamId); + if (!pending) return; + if (isRetryableAttachError(message)) { + this.retryPendingAttach(pending); + return; + } + this.failPendingAttach(pending, new Error(message)); + } + + private emitOutput(header: TerminalFrameHeader, data: Uint8Array) { + const sessionId = header.sessionId ?? ""; + const handles = this.handlesBySession.get(sessionId); + if (!handles) return; + const chunk: TerminalStreamChunk = { + sessionId, + projectPathKey: header.projectPathKey ?? "", + bytes: data, + startOffset: Number(header.startOffset ?? 0), + endOffset: Number(header.endOffset ?? 0), + }; + for (const handle of handles) { + handle.accept(chunk); + } + } + + private addHandle(handle: GatewayTerminalStreamHandle) { + const sessionId = handle.snapshot.session.id; + const handles = this.handlesBySession.get(sessionId) ?? new Set(); + handles.add(handle); + this.handlesBySession.set(sessionId, handles); + this.handlesByStream.set(handle.streamId, handle); + } + + reindexHandle( + handle: GatewayTerminalStreamHandle, + previousSessionId: string, + nextSessionId: string, + ) { + this.removeHandle(previousSessionId, handle); + const handles = this.handlesBySession.get(nextSessionId) ?? new Set(); + handles.add(handle); + this.handlesBySession.set(nextSessionId, handles); + this.handlesByStream.set(handle.streamId, handle); + } + + private removeHandle(sessionId: string, handleToRemove: GatewayTerminalStreamHandle) { + const handles = this.handlesBySession.get(sessionId); + if (!handles) return; + handles.delete(handleToRemove); + if (handles.size === 0) { + this.handlesBySession.delete(sessionId); + } + this.handlesByStream.delete(handleToRemove.streamId); + } + + private activeHandles() { + return [...this.handlesByStream.values()]; + } + + private handleSocketClosed() { + if (this.disposed) return; + for (const pending of this.pending.values()) { + pending.handle.markTransportDown(); + this.retryPendingAttach(pending); + } + for (const handle of this.activeHandles().filter( + (handle) => !this.pending.has(handle.streamId), + )) { + handle.markTransportDown(); + } + this.scheduleReconnect(); + } + + private async reattachActiveHandles() { + if (this.disposed) return; + const handles = this.activeHandles().filter((handle) => !this.pending.has(handle.streamId)); + if (handles.length === 0) return; + try { + await this.ensureConnected(); + await Promise.all( + handles.map((handle) => + this.send({ + kind: "attach", + streamId: handle.streamId, + sessionId: handle.snapshot.session.id, + projectPathKey: handle.snapshot.session.projectPathKey, + maxBytes: handle.maxBytes, + }), + ), + ); + } catch { + this.scheduleReconnect(1_000); + } + } + + private clearReconnectTimer() { + if (!this.reconnectTimer) return; + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private async sendPendingAttach(pending: PendingAttach) { + if (this.disposed || this.pending.get(pending.handle.streamId) !== pending) return; + try { + await this.send({ + kind: "attach", + streamId: pending.handle.streamId, + sessionId: pending.handle.snapshot.session.id, + projectPathKey: pending.handle.snapshot.session.projectPathKey, + maxBytes: pending.handle.maxBytes, + }); + } catch (error) { + if (isRetryableAttachError(errorMessage(error))) { + pending.handle.markTransportDown(); + this.retryPendingAttach(pending); + return; + } + this.failPendingAttach(pending, error instanceof Error ? error : new Error(String(error))); + } + } + + private retryPendingAttach(pending: PendingAttach) { + if (this.disposed || this.pending.get(pending.handle.streamId) !== pending) return; + pending.handle.markTransportDown(); + if (pending.retryTimerId) return; + pending.retryTimerId = setTimeout(() => { + pending.retryTimerId = null; + void this.sendPendingAttach(pending); + }, ATTACH_RETRY_MS); + } + + private failPendingAttach(pending: PendingAttach, error: Error) { + clearTimeout(pending.timeoutId); + if (pending.retryTimerId) clearTimeout(pending.retryTimerId); + this.pending.delete(pending.handle.streamId); + pending.handle.dispose(); + pending.reject(error); + } +} diff --git a/crates/agent-mobile/src/lib/tools/builtinTypes.ts b/crates/agent-mobile/src/lib/tools/builtinTypes.ts new file mode 100644 index 000000000..d67e88863 --- /dev/null +++ b/crates/agent-mobile/src/lib/tools/builtinTypes.ts @@ -0,0 +1,21 @@ +import type { BuiltinToolBundleShape } from "@liveagent/ui/contracts/builtinTools"; +import type { Tool, ToolCall, ToolResultMessage } from "../agentTypes"; + +export * from "@liveagent/ui/contracts/builtinTools"; +export type { + TaskItem, + TaskListResultDetails, + TaskListState, + TaskStatus, +} from "@liveagent/ui/contracts/task"; + +export type BuiltinToolExecutor = ( + toolCall: ToolCall, + signal?: AbortSignal, +) => Promise; + +export type BuiltinToolBundle = BuiltinToolBundleShape< + Tool, + BuiltinToolExecutor, + TExtra +>; diff --git a/crates/agent-mobile/src/lib/transcript-virtual/liveRangeExtractor.ts b/crates/agent-mobile/src/lib/transcript-virtual/liveRangeExtractor.ts new file mode 100644 index 000000000..521b84bcd --- /dev/null +++ b/crates/agent-mobile/src/lib/transcript-virtual/liveRangeExtractor.ts @@ -0,0 +1,18 @@ +import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual"; + +// Range extractor that force-mounts every row at or after the live boundary. +// Streaming rows must never unmount mid-run — losing them would drop +// Streamdown parse state and shiki/mermaid output, and remounting a growing +// row mid-stream re-parses everything it has produced so far. Settled rows +// (below the boundary) virtualize normally. +export function extractLiveRange(range: Range, liveStartIndex: number): number[] { + const base = defaultRangeExtractor(range); + if (liveStartIndex < 0 || liveStartIndex >= range.count) { + return base; + } + const indexes = new Set(base); + for (let index = liveStartIndex; index < range.count; index += 1) { + indexes.add(index); + } + return [...indexes].sort((a, b) => a - b); +} diff --git a/crates/agent-mobile/src/lib/uploadReadableFiles.ts b/crates/agent-mobile/src/lib/uploadReadableFiles.ts new file mode 100644 index 000000000..72ade4cae --- /dev/null +++ b/crates/agent-mobile/src/lib/uploadReadableFiles.ts @@ -0,0 +1,134 @@ +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { getGatewayOrigin } from "@/lib/gatewayOrigin"; + +type ImportReadableFilesResponse = { + files: PendingUploadedFile[]; + skipped: string[]; +}; + +const READABLE_FILE_KINDS = new Set([ + "text", + "image", + "pdf", + "notebook", + "word", + "spreadsheet", + "archive", +]); + +// Exported for tests. Gateway errors are JSON with an error/message field; +// anything else (a reverse proxy's HTML error page, a truncated body) must +// not leak into the UI verbatim — map it to a friendly message instead. +export async function readFetchError(response: Response, fallback: string) { + const fallbackWithStatus = `${fallback}(HTTP ${response.status})`; + if (response.status === 413) { + return "文件过大,服务器拒绝接收(HTTP 413)。请压缩文件后重试,或调大反向代理的请求体大小限制。"; + } + const raw = (await response.text().catch(() => "")).trim(); + if (!raw) { + return fallbackWithStatus; + } + + try { + const payload = JSON.parse(raw) as { error?: unknown; message?: unknown }; + const errorText = + typeof payload.error === "string" + ? payload.error.trim() + : typeof payload.message === "string" + ? payload.message.trim() + : ""; + return errorText || fallbackWithStatus; + } catch { + if (raw.startsWith("<") || raw.length > 300) { + return fallbackWithStatus; + } + return raw; + } +} + +function normalizeUploadedFile(value: unknown): PendingUploadedFile | null { + if (!value || typeof value !== "object") { + return null; + } + + const record = value as Record; + const relativePath = typeof record.relativePath === "string" ? record.relativePath.trim() : ""; + const fileName = typeof record.fileName === "string" ? record.fileName.trim() : ""; + const kind = typeof record.kind === "string" ? record.kind.trim() : ""; + const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : NaN; + + if (!relativePath || !fileName || !Number.isFinite(sizeBytes) || !READABLE_FILE_KINDS.has(kind)) { + return null; + } + + return { + relativePath, + absolutePath: + typeof record.absolutePath === "string" && record.absolutePath.trim() + ? record.absolutePath.trim() + : undefined, + fileName, + kind: kind as PendingUploadedFile["kind"], + sizeBytes, + }; +} + +export async function importReadableFiles( + token: string, + agentId: string, + workdir: string, + files: File[], +): Promise { + const normalizedToken = token.trim(); + const normalizedAgentId = agentId.trim(); + const normalizedWorkdir = workdir.trim(); + if (!normalizedToken) { + throw new Error("Gateway token is required"); + } + if (!normalizedAgentId) { + throw new Error("agent_id is required"); + } + if (!normalizedWorkdir) { + throw new Error("项目目录未选择,无法导入文件。"); + } + if (files.length === 0) { + return { files: [], skipped: [] }; + } + + const formData = new FormData(); + formData.set("workdir", normalizedWorkdir); + for (const file of files) { + formData.append("files", file, file.name); + } + + const url = new URL(`${getGatewayOrigin()}/api/files/import`); + url.searchParams.set("agent_id", normalizedAgentId); + + const response = await fetch(url.toString(), { + method: "POST", + headers: { + Authorization: `Bearer ${normalizedToken}`, + }, + body: formData, + }); + + if (!response.ok) { + throw new Error(await readFetchError(response, "导入文件失败")); + } + + const payload = (await response.json()) as { + files?: unknown[]; + skipped?: unknown[]; + }; + + return { + files: Array.isArray(payload.files) + ? payload.files + .map(normalizeUploadedFile) + .filter((file): file is PendingUploadedFile => file !== null) + : [], + skipped: Array.isArray(payload.skipped) + ? payload.skipped.filter((item): item is string => typeof item === "string") + : [], + }; +} diff --git a/crates/agent-mobile/src/lib/webSettings.ts b/crates/agent-mobile/src/lib/webSettings.ts new file mode 100644 index 000000000..09377314a --- /dev/null +++ b/crates/agent-mobile/src/lib/webSettings.ts @@ -0,0 +1,64 @@ +import { redactSettingsForWebStorage } from "@liveagent/ui/lib/settings/sync"; +import { getGatewayOrigin } from "@/lib/gatewayOrigin"; +import { type AppSettings, getDefaultSettings, normalizeSettings } from "@/lib/settings"; + +const WEB_SETTINGS_STORAGE_KEY = "liveagent.gateway.webui.settings.v1"; + +export type WebSettingsSaveState = + | { status: "idle" } + | { status: "saving" } + | { status: "saved" } + | { status: "error"; message: string }; + +export function getWebDefaultSettings(token: string): AppSettings { + const defaults = getDefaultSettings(); + return normalizeSettings({ + ...defaults, + system: { + ...defaults.system, + executionMode: "tools", + workdir: "", + }, + remote: { + ...defaults.remote, + enabled: token.trim() !== "", + gatewayUrl: getGatewayOrigin(), + token: token.trim(), + }, + }); +} + +export function loadWebSettings(token: string): AppSettings { + const fallback = getWebDefaultSettings(token); + try { + const raw = window.localStorage.getItem(WEB_SETTINGS_STORAGE_KEY); + if (!raw) { + return fallback; + } + const parsed = JSON.parse(raw) as Partial | null; + const normalized = redactSettingsForWebStorage( + normalizeSettings({ + ...fallback, + ...(parsed ?? {}), + remote: { + ...fallback.remote, + ...(parsed?.remote ?? {}), + gatewayUrl: fallback.remote.gatewayUrl, + token: token.trim(), + enabled: token.trim() !== "" || parsed?.remote?.enabled === true, + }, + }), + ); + window.localStorage.setItem(WEB_SETTINGS_STORAGE_KEY, JSON.stringify(normalized)); + return normalized; + } catch { + return fallback; + } +} + +export function persistWebSettings(settings: AppSettings): void { + window.localStorage.setItem( + WEB_SETTINGS_STORAGE_KEY, + JSON.stringify(redactSettingsForWebStorage(settings)), + ); +} diff --git a/crates/agent-mobile/src/lib/workspace-activity/gatewayWorkspaceActivityClient.ts b/crates/agent-mobile/src/lib/workspace-activity/gatewayWorkspaceActivityClient.ts new file mode 100644 index 000000000..74eaec05b --- /dev/null +++ b/crates/agent-mobile/src/lib/workspace-activity/gatewayWorkspaceActivityClient.ts @@ -0,0 +1,26 @@ +// WebUI adapter for WorkspaceActivityClient (web-only, not mirrored). +// +// Wraps the gateway socket's per-workdir workspace.subscribe/unsubscribe +// channel behind the mirrored WorkspaceActivityClient interface. Reconnect +// handling (re-subscribe + `{ kind: "reset" }` fan-out) lives in the socket +// client itself. + +import type { + WorkspaceActivityClient, + WorkspaceActivityEventPayload, +} from "@liveagent/ui/lib/workspace-activity/types"; + +type WorkspaceActivitySource = { + subscribeWorkspaceActivity( + workdir: string, + listener: (event: WorkspaceActivityEventPayload) => void, + ): () => void; +}; + +export function createGatewayWorkspaceActivityClient( + api: WorkspaceActivitySource, +): WorkspaceActivityClient { + return { + subscribe: (workdir, listener) => api.subscribeWorkspaceActivity(workdir, listener), + }; +} diff --git a/crates/agent-mobile/src/main.tsx b/crates/agent-mobile/src/main.tsx new file mode 100644 index 000000000..531018e22 --- /dev/null +++ b/crates/agent-mobile/src/main.tsx @@ -0,0 +1,28 @@ +import { GATEWAY_WEBUI_MARKER } from "@liveagent/ui/lib/runtimeEnv"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { DevicesAdminPage } from "./pages/DevicesAdminPage"; +import { StatusDashboardPage } from "./pages/StatusDashboardPage"; +import "./index.css"; +import "katex/dist/katex.min.css"; +import "react-complex-tree/lib/style-modern.css"; +import "streamdown/styles.css"; +import "./styles.css"; + +// 渲染前写入 WebUI 运行时标记(isGatewayWebuiRuntime 的唯一权威写入点)。 +document.documentElement.dataset.liveagentWebui = GATEWAY_WEBUI_MARKER; + +const dashboardPaths = new Set(["/dashboard", "/status-board", "/observatory"]); +// 独立管理页:/admin/devices(Agent 凭证管理,REST + 网关 Token)。 +const Root = dashboardPaths.has(window.location.pathname) + ? StatusDashboardPage + : window.location.pathname === "/admin/devices" + ? DevicesAdminPage + : App; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/crates/agent-mobile/src/pages/DevicesAdminPage.tsx b/crates/agent-mobile/src/pages/DevicesAdminPage.tsx new file mode 100644 index 000000000..fcaee0216 --- /dev/null +++ b/crates/agent-mobile/src/pages/DevicesAdminPage.tsx @@ -0,0 +1,19 @@ +import { ArrowLeft } from "@liveagent/ui/components/IconSet"; + +import { DevicesSection } from "./settings/DevicesSection"; + +// 保留可直接访问的管理路由;WebUI 日常入口位于设置页内,并与此处复用同一内容组件。 +export function DevicesAdminPage() { + return ( + + ); +} diff --git a/crates/agent-mobile/src/pages/LoginPage.tsx b/crates/agent-mobile/src/pages/LoginPage.tsx new file mode 100644 index 000000000..c3340d583 --- /dev/null +++ b/crates/agent-mobile/src/pages/LoginPage.tsx @@ -0,0 +1,177 @@ +import { + ArrowRight, + Globe, + History, + Key, + Lock, + MessageSquareText, + Shield, + Timer, +} from "@liveagent/ui/components/IconSet"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { Input } from "@liveagent/ui/components/ui/input"; +import { Textarea } from "@liveagent/ui/components/ui/textarea"; +import { cn } from "@liveagent/ui/lib/shared/utils"; +import { useState } from "react"; + +type LoginPageProps = { + gatewayUrl: string; + token: string; + error: string | null; + isSubmitting: boolean; + onGatewayUrlChange: (gatewayUrl: string) => void; + onTokenChange: (token: string) => void; + onSubmit: () => void; +}; + +const features = [ + { + icon: MessageSquareText, + title: "Remote Chat", + desc: "按桌面端式样查看 token、thinking、tool_call 与 tool_result。", + accent: "login-feat--blue", + }, + { + icon: History, + title: "History Resume", + desc: "从远程历史回填会话并继续对话,而不是只看原始 JSON。", + accent: "login-feat--violet", + }, + { + icon: Timer, + title: "Cron Control", + desc: "在浏览器里完成任务查看、创建、更新与删除的转发调试。", + accent: "login-feat--amber", + }, +]; + +export function LoginPage({ + gatewayUrl, + token, + error, + isSubmitting, + onGatewayUrlChange, + onTokenChange, + onSubmit, +}: LoginPageProps) { + const [isUrlFocused, setIsUrlFocused] = useState(false); + const [isTokenFocused, setIsTokenFocused] = useState(false); + + return ( +
+ {/* Subtle mesh gradient backdrop */} +