diff --git a/package.json b/package.json index f657beb..f52ba8a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "codemirror": "^6.0.2", "lodash-es": "^4.17.21", "lucide-vue-next": "^0.539.0", + "markdown-it": "^14.2.0", "vue": "^3.5.13", "vue-codemirror": "^6.1.1", "vue3-markdown-it": "^1.0.10" @@ -43,6 +44,7 @@ "@tailwindcss/postcss": "^4.1.11", "@tauri-apps/cli": "^2", "@types/lodash-es": "^4.17.12", + "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "ali-oss": "^6.23.0", "autoprefixer": "^10.4.21", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7630d5b..9c21961 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,9 +14,11 @@ dependencies = [ "flate2", "futures-util", "log", + "notify", "regex", "reqwest 0.11.27", "rfd 0.15.4", + "rusqlite", "serde", "serde_json", "tar", @@ -60,6 +62,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -374,11 +388,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -494,7 +508,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "cairo-sys-rs", "glib", "libc", @@ -697,7 +711,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -710,7 +724,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -723,7 +737,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "core-foundation 0.10.1", "libc", ] @@ -948,7 +962,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -957,7 +971,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.6.1", "libc", "objc2 0.6.4", @@ -1181,6 +1195,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1310,6 +1336,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1573,7 +1608,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "futures-channel", "futures-core", "futures-executor", @@ -1708,12 +1743,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2099,6 +2152,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2114,7 +2187,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "cfg-if", "libc", ] @@ -2245,11 +2318,31 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "serde", "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.12.1", + "libc", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2305,11 +2398,22 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "libc", "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -2401,6 +2505,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -2430,7 +2546,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.12", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2456,7 +2572,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "jni-sys", "log", "ndk-sys", @@ -2486,13 +2602,32 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "cfg-if", "cfg_aliases", "libc", "memoffset", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.12.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2562,7 +2697,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.6.1", "objc2 0.6.4", "objc2-core-foundation", @@ -2575,7 +2710,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "objc2 0.6.4", "objc2-foundation 0.3.1", ] @@ -2596,7 +2731,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "dispatch2", "objc2 0.6.4", ] @@ -2607,7 +2742,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -2655,7 +2790,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2667,7 +2802,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.6.1", "libc", "objc2 0.6.4", @@ -2680,7 +2815,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2691,7 +2826,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2703,7 +2838,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2716,7 +2851,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -2728,7 +2863,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.6.1", "objc2 0.6.4", "objc2-cloud-kit", @@ -2758,7 +2893,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.6.1", "objc2 0.6.4", "objc2-app-kit", @@ -2799,7 +2934,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -3053,7 +3188,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "crc32fast", "fdeflate", "flate2", @@ -3245,7 +3380,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", ] [[package]] @@ -3432,6 +3567,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.12.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -3459,7 +3608,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys", @@ -3574,7 +3723,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3597,7 +3746,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "cssparser", "derive_more", "log", @@ -4103,7 +4252,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "block2 0.6.1", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -4462,7 +4611,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.12", - "toml 0.9.5", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4593,7 +4742,7 @@ dependencies = [ "bytes", "io-uring", "libc", - "mio", + "mio 1.0.4", "pin-project-lite", "slab", "socket2 0.6.0", @@ -4763,7 +4912,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "bytes", "futures-util", "http 1.3.1", @@ -4837,7 +4986,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.12", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5158,7 +5307,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "rustix", "wayland-backend", "wayland-scanner", @@ -5170,7 +5319,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5819,7 +5968,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.12.1", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7f383fb..8ffc93d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ log = "0.4" fern = "0.7.1" dirs = "6.0.0" regex = "1.11.1" +rusqlite = { version = "0.32", features = ["bundled"] } reqwest = { version = "0.11", features = ["json", "stream"] } futures-util = "0.3" rfd = "0.15" @@ -39,3 +40,4 @@ flate2 = "1.0" tar = "0.4" xz2 = "0.1" zstd = "0.13" +notify = "6" diff --git a/src-tauri/src/ai.rs b/src-tauri/src/ai.rs new file mode 100644 index 0000000..996e79b --- /dev/null +++ b/src-tauri/src/ai.rs @@ -0,0 +1,236 @@ +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tauri::{AppHandle, Emitter}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +fn default_base(provider: &str) -> &'static str { + match provider { + "anthropic" => "https://api.anthropic.com", + "deepseek" => "https://api.deepseek.com", + // openai 及其它兼容服务 + _ => "https://api.openai.com/v1", + } +} + +/// 统一的 AI 对话命令。provider: anthropic | openai | deepseek(其它按 OpenAI 兼容处理)。 +#[tauri::command] +pub async fn ai_chat( + provider: String, + base_url: Option, + api_key: String, + model: String, + system: Option, + messages: Vec, +) -> Result { + if api_key.trim().is_empty() { + return Err("未配置 API Key".to_string()); + } + + let base = base_url + .filter(|b| !b.trim().is_empty()) + .unwrap_or_else(|| default_base(&provider).to_string()); + let base = base.trim_end_matches('/'); + + let client = reqwest::Client::new(); + + if provider == "anthropic" { + let url = format!("{}/v1/messages", base); + let body = json!({ + "model": model, + "max_tokens": 4096, + "system": system.unwrap_or_default(), + "messages": messages.iter().map(|m| json!({"role": m.role, "content": m.content})).collect::>(), + }); + + let resp = client + .post(&url) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = resp.status(); + let data: Value = resp + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + if !status.is_success() { + return Err(extract_error(&data, status.as_u16())); + } + + // content 是文本块数组 + let text = data["content"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|b| b["text"].as_str()) + .collect::>() + .join("") + }) + .unwrap_or_default(); + Ok(text) + } else { + // OpenAI 兼容 + let url = format!("{}/chat/completions", base); + let mut msgs: Vec = Vec::new(); + if let Some(sys) = system.filter(|s| !s.trim().is_empty()) { + msgs.push(json!({"role": "system", "content": sys})); + } + for m in &messages { + msgs.push(json!({"role": m.role, "content": m.content})); + } + let body = json!({ "model": model, "messages": msgs }); + + let resp = client + .post(&url) + .header("authorization", format!("Bearer {}", api_key)) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = resp.status(); + let data: Value = resp + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + if !status.is_success() { + return Err(extract_error(&data, status.as_u16())); + } + + let text = data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or_default() + .to_string(); + Ok(text) + } +} + +/// 流式 AI 对话。逐段通过 `ai-stream-delta` 事件返回,完成时命令 Promise resolve。 +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn ai_chat_stream( + app: AppHandle, + stream_id: String, + provider: String, + base_url: Option, + api_key: String, + model: String, + system: Option, + messages: Vec, +) -> Result<(), String> { + if api_key.trim().is_empty() { + return Err("未配置 API Key".to_string()); + } + + let is_anthropic = provider == "anthropic"; + let base = base_url + .filter(|b| !b.trim().is_empty()) + .unwrap_or_else(|| default_base(&provider).to_string()); + let base = base.trim_end_matches('/'); + let client = reqwest::Client::new(); + + let request = if is_anthropic { + let body = json!({ + "model": model, + "max_tokens": 4096, + "stream": true, + "system": system.unwrap_or_default(), + "messages": messages.iter().map(|m| json!({"role": m.role, "content": m.content})).collect::>(), + }); + client + .post(format!("{}/v1/messages", base)) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + } else { + let mut msgs: Vec = Vec::new(); + if let Some(sys) = system.filter(|s| !s.trim().is_empty()) { + msgs.push(json!({"role": "system", "content": sys})); + } + for m in &messages { + msgs.push(json!({"role": m.role, "content": m.content})); + } + let body = json!({ "model": model, "messages": msgs, "stream": true }); + client + .post(format!("{}/chat/completions", base)) + .header("authorization", format!("Bearer {}", api_key)) + .header("content-type", "application/json") + .json(&body) + }; + + let resp = request + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = resp.status(); + if !status.is_success() { + let data: Value = resp.json().await.unwrap_or(Value::Null); + return Err(extract_error(&data, status.as_u16())); + } + + let mut stream = resp.bytes_stream(); + let mut buf = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("读取流失败: {}", e))?; + buf.push_str(&String::from_utf8_lossy(&chunk)); + + // 按行处理 SSE + while let Some(pos) = buf.find('\n') { + let line = buf[..pos].trim().to_string(); + buf.drain(..=pos); + + let data = match line.strip_prefix("data:") { + Some(d) => d.trim(), + None => continue, + }; + if data.is_empty() || data == "[DONE]" { + continue; + } + + let v: Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(_) => continue, + }; + let delta = if is_anthropic { + v["delta"]["text"].as_str() + } else { + v["choices"][0]["delta"]["content"].as_str() + }; + if let Some(text) = delta { + if !text.is_empty() { + let _ = app.emit( + "ai-stream-delta", + json!({"stream_id": stream_id, "delta": text}), + ); + } + } + } + } + + Ok(()) +} + +fn extract_error(data: &Value, status: u16) -> String { + let msg = data["error"]["message"] + .as_str() + .or_else(|| data["error"].as_str()) + .or_else(|| data["message"].as_str()) + .unwrap_or("未知错误"); + format!("AI 接口错误 ({}): {}", status, msg) +} diff --git a/src-tauri/src/ai_history.rs b/src-tauri/src/ai_history.rs new file mode 100644 index 0000000..61e59aa --- /dev/null +++ b/src-tauri/src/ai_history.rs @@ -0,0 +1,118 @@ +use crate::execution::get_codeforge_db_path; +use rusqlite::{Connection, params}; +use serde::Serialize; +use std::sync::Mutex as StdMutex; +use tauri::State; + +#[derive(Serialize)] +pub struct AiConversationMeta { + pub id: String, + pub title: String, + pub updated_at: i64, +} + +/// AI 对话历史,存于与执行历史相同的 codeforge.sqlite 库 +pub struct AiHistory { + conn: StdMutex, +} + +impl AiHistory { + pub fn new() -> Result { + let db_path = get_codeforge_db_path()?; + let conn = Connection::open(&db_path).map_err(|e| format!("打开数据库失败: {}", e))?; + // 并发读写更稳 + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + conn.execute( + "CREATE TABLE IF NOT EXISTS ai_conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + messages TEXT NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + ) + .map_err(|e| format!("初始化 AI 对话表失败: {}", e))?; + + Ok(Self { + conn: StdMutex::new(conn), + }) + } +} + +#[tauri::command] +pub async fn save_ai_conversation( + id: String, + title: String, + messages: String, + updated_at: i64, + history: State<'_, AiHistory>, +) -> Result<(), String> { + let conn = history + .conn + .lock() + .map_err(|_| "数据库锁错误".to_string())?; + conn.execute( + "INSERT INTO ai_conversations (id, title, messages, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET title=?2, messages=?3, updated_at=?4", + params![id, title, messages, updated_at], + ) + .map_err(|e| format!("保存 AI 对话失败: {}", e))?; + Ok(()) +} + +#[tauri::command] +pub async fn list_ai_conversations( + history: State<'_, AiHistory>, +) -> Result, String> { + let conn = history + .conn + .lock() + .map_err(|_| "数据库锁错误".to_string())?; + let mut stmt = conn + .prepare("SELECT id, title, updated_at FROM ai_conversations ORDER BY updated_at DESC") + .map_err(|e| format!("读取 AI 对话失败: {}", e))?; + let rows = stmt + .query_map([], |row| { + Ok(AiConversationMeta { + id: row.get(0)?, + title: row.get(1)?, + updated_at: row.get(2)?, + }) + }) + .map_err(|e| format!("读取 AI 对话失败: {}", e))?; + rows.collect::, _>>() + .map_err(|e| format!("读取 AI 对话失败: {}", e)) +} + +/// 返回该会话的 messages JSON 字符串 +#[tauri::command] +pub async fn get_ai_conversation( + id: String, + history: State<'_, AiHistory>, +) -> Result { + let conn = history + .conn + .lock() + .map_err(|_| "数据库锁错误".to_string())?; + conn.query_row( + "SELECT messages FROM ai_conversations WHERE id = ?1", + params![id], + |row| row.get::<_, String>(0), + ) + .map_err(|e| format!("读取 AI 对话失败: {}", e)) +} + +#[tauri::command] +pub async fn delete_ai_conversation( + id: String, + history: State<'_, AiHistory>, +) -> Result<(), String> { + let conn = history + .conn + .lock() + .map_err(|_| "数据库锁错误".to_string())?; + conn.execute("DELETE FROM ai_conversations WHERE id = ?1", params![id]) + .map_err(|e| format!("删除 AI 对话失败: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 74a9c81..6f742db 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -12,17 +12,18 @@ static CONFIG_MANAGER: Mutex> = Mutex::new(None); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EditorConfig { - pub indent_with_tab: Option, // 是否使用 tab 缩进 - pub tab_size: Option, // tab 缩进, 空格数,默认为 2 - pub theme: Option, // 编辑器主题 - pub font_size: Option, // 编辑器字体大小 - pub font_family: Option, // 编辑器字体 - pub show_line_numbers: Option, // 是否显示行号 - pub show_function_help: Option, // 是否显示函数帮助 - pub space_dot_omission: Option, // 是否显示空格省略 - pub layout: Option, // 编辑器/控制台布局: horizontal | vertical | editor - pub last_direction: Option, // 仅编辑器模式下控制台弹出方向: horizontal | vertical - pub max_open_file_size: Option, // 打开文件大小上限(MB),超过则拒绝打开 + pub indent_with_tab: Option, // 是否使用 tab 缩进 + pub tab_size: Option, // tab 缩进, 空格数,默认为 2 + pub theme: Option, // 编辑器主题 + pub font_size: Option, // 编辑器字体大小 + pub font_family: Option, // 编辑器字体 + pub show_line_numbers: Option, // 是否显示行号 + pub show_function_help: Option, // 是否显示函数帮助 + pub space_dot_omission: Option, // 是否显示空格省略 + pub layout: Option, // 编辑器/控制台布局: horizontal | vertical | editor + pub last_direction: Option, // 仅编辑器模式下控制台弹出方向: horizontal | vertical + pub max_open_file_size: Option, // 打开文件大小上限(MB),超过则拒绝打开 + pub run_save_strategy: Option, // 运行未保存文件策略: auto-save | ask | temp-copy } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,6 +72,7 @@ impl Default for AppConfig { layout: Some("horizontal".to_string()), last_direction: Some("horizontal".to_string()), max_open_file_size: Some(5), + run_save_strategy: Some("auto-save".to_string()), }), environment_mirror: Some(EnvironmentMirrorConfig { enabled: Some(false), @@ -140,6 +142,7 @@ impl ConfigManager { layout: Some("horizontal".to_string()), last_direction: Some("horizontal".to_string()), max_open_file_size: Some(5), + run_save_strategy: Some("auto-save".to_string()), }); println!("读取配置 -> 添加默认 editor 配置"); } @@ -262,6 +265,7 @@ impl ConfigManager { layout: Some("horizontal".to_string()), last_direction: Some("horizontal".to_string()), max_open_file_size: Some(5), + run_save_strategy: Some("auto-save".to_string()), }), environment_mirror: Some(EnvironmentMirrorConfig { enabled: Some(false), diff --git a/src-tauri/src/execution.rs b/src-tauri/src/execution.rs index 279a157..333ba73 100644 --- a/src-tauri/src/execution.rs +++ b/src-tauri/src/execution.rs @@ -1,11 +1,13 @@ use crate::plugins::{CodeExecutionRequest, ExecutionResult, PluginManager}; use log::{error, info, warn}; +use rusqlite::{Connection, params}; +use serde::Serialize; use std::collections::HashMap; use std::fs; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; use std::process::{Command, Stdio}; -use std::sync::{Arc, OnceLock, mpsc}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock, mpsc}; use std::thread; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, State}; @@ -21,9 +23,154 @@ pub struct ExecutionTask { pub stop_flag: Arc>, } -pub type ExecutionHistory = Mutex>; pub type PluginManagerState = Mutex; +#[derive(Debug, Serialize)] +pub struct ExecutionHistoryPage { + pub items: Vec, + pub total: u64, +} + +pub struct ExecutionHistory { + conn: StdMutex, +} + +impl ExecutionHistory { + pub fn new() -> Result { + let db_path = get_codeforge_db_path()?; + let conn = + Connection::open(&db_path).map_err(|e| format!("打开执行历史数据库失败: {}", e))?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS execution_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + success INTEGER NOT NULL, + code TEXT NOT NULL, + stdout TEXT NOT NULL, + stderr TEXT NOT NULL, + execution_time INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + language TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("初始化执行历史数据库失败: {}", e))?; + + Ok(Self { + conn: StdMutex::new(conn), + }) + } + + fn insert(&self, result: &ExecutionResult) -> Result<(), String> { + let conn = self + .conn + .lock() + .map_err(|_| "执行历史数据库锁错误".to_string())?; + + conn.execute( + "INSERT INTO execution_history + (success, code, stdout, stderr, execution_time, timestamp, language) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + if result.success { 1 } else { 0 }, + &result.code, + &result.stdout, + &result.stderr, + result.execution_time as i64, + result.timestamp as i64, + &result.language, + ], + ) + .map_err(|e| format!("保存执行历史失败: {}", e))?; + + Ok(()) + } + + fn list(&self) -> Result, String> { + let conn = self + .conn + .lock() + .map_err(|_| "执行历史数据库锁错误".to_string())?; + let mut statement = conn + .prepare( + "SELECT success, code, stdout, stderr, execution_time, timestamp, language + FROM execution_history + ORDER BY id ASC", + ) + .map_err(|e| format!("读取执行历史失败: {}", e))?; + + let rows = statement + .query_map([], |row| { + Ok(ExecutionResult { + success: row.get::<_, i64>(0)? != 0, + code: row.get(1)?, + stdout: row.get(2)?, + stderr: row.get(3)?, + execution_time: row.get::<_, i64>(4)? as u128, + timestamp: row.get::<_, i64>(5)? as u64, + language: row.get(6)?, + }) + }) + .map_err(|e| format!("读取执行历史失败: {}", e))?; + + rows.collect::, _>>() + .map_err(|e| format!("读取执行历史失败: {}", e)) + } + + fn list_page(&self, offset: u64, limit: u64) -> Result { + let limit = limit.clamp(1, 100); + let conn = self + .conn + .lock() + .map_err(|_| "执行历史数据库锁错误".to_string())?; + + let total = conn + .query_row("SELECT COUNT(*) FROM execution_history", [], |row| { + row.get::<_, i64>(0) + }) + .map_err(|e| format!("统计执行历史失败: {}", e))? as u64; + + let mut statement = conn + .prepare( + "SELECT success, code, stdout, stderr, execution_time, timestamp, language + FROM execution_history + ORDER BY id DESC + LIMIT ?1 OFFSET ?2", + ) + .map_err(|e| format!("读取执行历史失败: {}", e))?; + + let rows = statement + .query_map(params![limit as i64, offset as i64], |row| { + Ok(ExecutionResult { + success: row.get::<_, i64>(0)? != 0, + code: row.get(1)?, + stdout: row.get(2)?, + stderr: row.get(3)?, + execution_time: row.get::<_, i64>(4)? as u128, + timestamp: row.get::<_, i64>(5)? as u64, + language: row.get(6)?, + }) + }) + .map_err(|e| format!("读取执行历史失败: {}", e))?; + + let items = rows + .collect::, _>>() + .map_err(|e| format!("读取执行历史失败: {}", e))?; + + Ok(ExecutionHistoryPage { items, total }) + } + + fn clear(&self) -> Result<(), String> { + let conn = self + .conn + .lock() + .map_err(|_| "执行历史数据库锁错误".to_string())?; + conn.execute("DELETE FROM execution_history", []) + .map_err(|e| format!("清空执行历史失败: {}", e))?; + Ok(()) + } +} + // 全局任务管理器 type TaskManager = Arc>>; static TASK_MANAGER: OnceLock = OnceLock::new(); @@ -50,6 +197,20 @@ fn get_codeforge_cache_dir(language: &str) -> Result { Ok(cache_dir) } +pub fn get_codeforge_db_path() -> Result { + let home_dir = dirs::home_dir().ok_or("无法获取用户主目录")?; + let codeforge_dir = home_dir.join(".codeforge"); + fs::create_dir_all(&codeforge_dir).map_err(|e| format!("创建配置目录失败: {}", e))?; + let db_path = codeforge_dir.join("codeforge.sqlite"); + let old_db_path = codeforge_dir.join("execution_history.sqlite"); + + if !db_path.exists() && old_db_path.exists() { + fs::rename(&old_db_path, &db_path).map_err(|e| format!("迁移执行历史数据库失败: {}", e))?; + } + + Ok(db_path) +} + // 检查是否应该过滤 stderr 行 fn should_filter_stderr_line(language: &str, line: &str) -> bool { match language { @@ -68,32 +229,32 @@ fn should_filter_stderr_line(language: &str, line: &str) -> bool { } } -// 停止执行命令 +// 停止执行命令(按 task_id 停止指定的运行任务) #[tauri::command] -pub async fn stop_execution(language: String) -> Result { +pub async fn stop_execution(task_id: String) -> Result { let task_manager = init_task_manager(); let mut guard = task_manager.lock().await; - if let Some(task) = guard.remove(&language) { + if let Some(task) = guard.remove(&task_id) { // 设置停止标志 { let mut stop_flag = task.stop_flag.lock().await; *stop_flag = true; } - info!("停止执行 -> 成功设置停止标志给语言 [ {} ]", language); + info!("停止执行 -> 成功设置停止标志给任务 [ {} ]", task_id); Ok(true) } else { - warn!("停止执行 -> 语言 [ {} ] 没有正在运行的任务", language); + warn!("停止执行 -> 任务 [ {} ] 没有正在运行", task_id); Ok(false) } } -// 检查是否有正在运行的任务 +// 检查指定任务是否正在运行 #[tauri::command] -pub async fn is_execution_running(language: String) -> Result { +pub async fn is_execution_running(task_id: String) -> Result { let task_manager = init_task_manager(); let guard = task_manager.lock().await; - Ok(guard.contains_key(&language)) + Ok(guard.contains_key(&task_id)) } // 通用的代码执行函数 @@ -104,31 +265,43 @@ pub async fn execute_code( plugin_manager: State<'_, PluginManagerState>, app: AppHandle, ) -> Result { - info!("执行代码 -> 调用插件 [ {} ] 开始", request.language); - - // 先停止之前可能正在运行的任务 - let _ = stop_execution(request.language.clone()).await; + let task_id = request.task_id.clone(); + info!( + "执行代码 -> 调用插件 [ {} ] 任务 [ {} ] 开始", + request.language, task_id + ); let manager = plugin_manager.lock().await; let plugin = manager .get_plugin(&request.language) .ok_or_else(|| format!("Unsupported language: {}", request.language))?; - // 使用 .codeforge/cache/plugin/ 目录 - let temp_dir = get_codeforge_cache_dir(&request.language)?; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - let file_work = format!("Codeforge_{}_{}", request.language, timestamp); - let work_dir = temp_dir.join(&file_work); - fs::create_dir_all(&work_dir).map_err(|e| format!("创建工作目录失败: {}", e))?; - let file_name = format!("{}.{}", file_work, plugin.get_file_extension()); - let file_path = work_dir.join(&file_name); - - // 写入代码到临时文件 - fs::write(&file_path, &request.code) - .map_err(|e| format!("Failed to write temporary file: {}", e))?; + // 决定运行的文件与工作目录: + // - 提供了 file_path:就地运行该文件,工作目录为其所在目录(多文件 import/相对路径正确) + // - 否则:写入 .codeforge/cache/plugins/ 临时目录后运行 + let (file_path, cwd): (PathBuf, Option) = if let Some(fp) = request.file_path.clone() { + let p = PathBuf::from(&fp); + let dir = p.parent().map(|d| d.to_path_buf()); + (p, dir) + } else { + let temp_dir = get_codeforge_cache_dir(&request.language)?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let file_work = format!("Codeforge_{}_{}", request.language, timestamp); + let work_dir = temp_dir.join(&file_work); + fs::create_dir_all(&work_dir).map_err(|e| format!("创建工作目录失败: {}", e))?; + let file_name = format!("{}.{}", file_work, plugin.get_file_extension()); + let fp = work_dir.join(&file_name); + + // 写入代码到临时文件 + fs::write(&fp, &request.code) + .map_err(|e| format!("Failed to write temporary file: {}", e))?; + + let home = plugin.get_execute_home(); + (fp, home) + }; let _processed_code = plugin .pre_execute_hook(&request.code, file_path.to_str().unwrap()) @@ -143,7 +316,11 @@ pub async fn execute_code( let start_time = std::time::Instant::now(); let cmd = plugin.get_command(None, false, Some(file_path.to_string_lossy().to_string())); - let args = plugin.get_execute_args(file_path.to_str().unwrap()); + let mut args = plugin.get_execute_args(file_path.to_str().unwrap()); + // 追加用户自定义运行参数 + if let Some(extra) = &request.args { + args.extend(extra.iter().cloned()); + } info!( "执行代码 -> 调用插件 [ {} ] 执行命令 {} 携带参数 {}", request.language, @@ -155,7 +332,8 @@ pub async fn execute_code( let _ = app.emit( "code-execution-start", serde_json::json!({ - "language": request.language + "language": request.language, + "task_id": task_id }), ); @@ -166,9 +344,16 @@ pub async fn execute_code( .stdout(Stdio::piped()) .stderr(Stdio::piped()); - // 如果插件有 execute_home,设置工作目录 - if let Some(execute_home) = plugin.get_execute_home() { - command.current_dir(&execute_home); + // 有标准输入则用管道写入,否则关闭 stdin 避免程序读取时挂起 + if request.stdin.is_some() { + command.stdin(Stdio::piped()); + } else { + command.stdin(Stdio::null()); + } + + // 设置工作目录(就地运行为文件目录,否则为插件 execute_home) + if let Some(dir) = &cwd { + command.current_dir(dir); } let mut child = match command.spawn() { @@ -185,6 +370,7 @@ pub async fn execute_code( "code-execution-complete", serde_json::json!({ "language": request.language, + "task_id": task_id, "success": false }), ); @@ -197,6 +383,13 @@ pub async fn execute_code( } }; + // 写入标准输入后关闭管道(让程序读到 EOF) + if let Some(input) = &request.stdin { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(input.as_bytes()); + } + } + // 创建停止标志 let stop_flag = Arc::new(tokio::sync::Mutex::new(false)); @@ -205,7 +398,7 @@ pub async fn execute_code( { let mut guard = task_manager.lock().await; guard.insert( - request.language.clone(), + task_id.clone(), ExecutionTask { language: request.language.clone(), process_id: child.id(), @@ -261,13 +454,14 @@ pub async fn execute_code( // 从任务管理器中移除 { let mut guard = task_manager.lock().await; - guard.remove(&request.language); + guard.remove(&task_id); } let _ = app.emit( "code-execution-stopped", serde_json::json!({ - "language": request.language + "language": request.language, + "task_id": task_id }), ); @@ -284,13 +478,14 @@ pub async fn execute_code( // 从任务管理器中移除 { let mut guard = task_manager.lock().await; - guard.remove(&request.language); + guard.remove(&task_id); } let _ = app.emit( "code-execution-timeout", serde_json::json!({ - "language": request.language + "language": request.language, + "task_id": task_id }), ); @@ -310,7 +505,8 @@ pub async fn execute_code( serde_json::json!({ "type": "stdout", "content": line, - "language": request.language + "language": request.language, + "task_id": task_id }), ); } @@ -325,7 +521,8 @@ pub async fn execute_code( serde_json::json!({ "type": "stderr", "content": line, - "language": request.language + "language": request.language, + "task_id": task_id }), ); } @@ -342,7 +539,8 @@ pub async fn execute_code( serde_json::json!({ "type": "stdout", "content": line, - "language": request.language + "language": request.language, + "task_id": task_id }), ); } @@ -355,7 +553,8 @@ pub async fn execute_code( serde_json::json!({ "type": "stderr", "content": line, - "language": request.language + "language": request.language, + "task_id": task_id }), ); } @@ -372,11 +571,12 @@ pub async fn execute_code( // 从任务管理器中移除 { let mut guard = task_manager.lock().await; - guard.remove(&request.language); + guard.remove(&task_id); } let mut result = ExecutionResult { success: status.success(), + code: request.code.clone(), stdout: stdout_lines.join("\n"), stderr: stderr_lines.join("\n"), execution_time, @@ -395,7 +595,8 @@ pub async fn execute_code( serde_json::json!({ "type": "stdout", "content": "代码执行成功 (无输出)", - "language": request.language + "language": request.language, + "task_id": task_id }), ); } @@ -404,18 +605,14 @@ pub async fn execute_code( "code-execution-complete", serde_json::json!({ "language": request.language, + "task_id": task_id, "success": result.success, "execution_time": result.execution_time }), ); drop(manager); - let mut history_guard = history.lock().await; - history_guard.push(result.clone()); - - if history_guard.len() > 100 { - history_guard.remove(0); - } + history.insert(&result)?; info!("执行代码 -> 调用插件 [ {} ] 完成", request.language); return Ok(result); @@ -431,13 +628,14 @@ pub async fn execute_code( // 从任务管理器中移除 { let mut guard = task_manager.lock().await; - guard.remove(&request.language); + guard.remove(&task_id); } let _ = app.emit( "code-execution-error", serde_json::json!({ "language": request.language, + "task_id": task_id, "error": e.to_string() }), ); @@ -453,14 +651,21 @@ pub async fn execute_code( pub async fn get_execution_history( history: State<'_, ExecutionHistory>, ) -> Result, String> { - let history_guard = history.lock().await; - Ok(history_guard.clone()) + history.list() +} + +// 分页获取执行历史 +#[tauri::command] +pub async fn get_execution_history_page( + offset: u64, + limit: u64, + history: State<'_, ExecutionHistory>, +) -> Result { + history.list_page(offset, limit) } // 清空执行历史 #[tauri::command] pub async fn clear_execution_history(history: State<'_, ExecutionHistory>) -> Result<(), String> { - let mut history_guard = history.lock().await; - history_guard.clear(); - Ok(()) + history.clear() } diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index 7c56329..3f85978 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -1,3 +1,4 @@ +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde::Serialize; use std::collections::HashMap; use std::fs; @@ -5,6 +6,7 @@ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::Path; use std::sync::Mutex; use std::time::SystemTime; +use tauri::{AppHandle, Emitter}; #[derive(Serialize)] pub struct FileNode { @@ -52,6 +54,53 @@ pub fn read_directory_tree(path: String) -> Result, String> { /// 默认文本文件大小上限(MB),超过则拒绝打开,避免编辑器卡死 const DEFAULT_MAX_FILE_SIZE_MB: u64 = 5; +/// 快速打开的文件数量上限 +const MAX_LIST_FILES: usize = 20000; + +/// 递归列出目录下所有文件(用于 Cmd+P 快速打开)。跳过隐藏目录与常见重目录。 +#[tauri::command] +pub fn list_files(path: String) -> Result, String> { + let root = Path::new(&path); + if !root.is_dir() { + return Err(format!("不是有效目录: {}", path)); + } + + let ignore = ["node_modules", "target", "dist", "build", ".next", ".cache"]; + let mut files: Vec = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + if files.len() >= MAX_LIST_FILES { + break; + } + let read = match fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, + }; + for entry in read.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name == ".DS_Store" { + continue; + } + let p = entry.path(); + if p.is_dir() { + // 跳过隐藏目录与常见重目录 + if name.starts_with('.') || ignore.contains(&name.as_str()) { + continue; + } + stack.push(p); + } else { + files.push(p.to_string_lossy().to_string()); + if files.len() >= MAX_LIST_FILES { + break; + } + } + } + } + + Ok(files) +} + /// 读取文本文件内容(绕开 fs 插件 scope 限制)。 /// max_size_mb 为打开大小上限(MB),不传则用默认 5MB。 #[tauri::command] @@ -77,6 +126,95 @@ pub fn write_file_text(path: String, content: String) -> Result<(), String> { fs::write(&path, content).map_err(|e| format!("写入文件失败: {}", e)) } +/// 新建空文件 +#[tauri::command] +pub fn create_file(path: String) -> Result<(), String> { + if Path::new(&path).exists() { + return Err("文件已存在".to_string()); + } + fs::write(&path, "").map_err(|e| format!("创建文件失败: {}", e)) +} + +/// 新建目录 +#[tauri::command] +pub fn create_directory(path: String) -> Result<(), String> { + if Path::new(&path).exists() { + return Err("目录已存在".to_string()); + } + fs::create_dir_all(&path).map_err(|e| format!("创建目录失败: {}", e)) +} + +/// 重命名/移动 +#[tauri::command] +pub fn rename_path(from: String, to: String) -> Result<(), String> { + if Path::new(&to).exists() { + return Err("目标已存在".to_string()); + } + fs::rename(&from, &to).map_err(|e| format!("重命名失败: {}", e)) +} + +/// 删除文件或目录(递归) +#[tauri::command] +pub fn delete_path(path: String) -> Result<(), String> { + let p = Path::new(&path); + if p.is_dir() { + fs::remove_dir_all(p).map_err(|e| format!("删除目录失败: {}", e)) + } else { + fs::remove_file(p).map_err(|e| format!("删除文件失败: {}", e)) + } +} + +// 全局目录监听器(保持存活;切换目录时替换旧的) +static WATCHER: Mutex> = Mutex::new(None); + +/// 监听目录变化,变化时向前端发送 `fs-changed` 事件 +#[tauri::command] +pub fn watch_directory(path: String, app: AppHandle) -> Result<(), String> { + let app_handle = app.clone(); + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if res.is_ok() { + let _ = app_handle.emit("fs-changed", ()); + } + }) + .map_err(|e| format!("创建文件监听失败: {}", e))?; + + watcher + .watch(Path::new(&path), RecursiveMode::Recursive) + .map_err(|e| format!("监听目录失败: {}", e))?; + + // 替换旧监听器(drop 旧的即停止监听) + let mut guard = WATCHER.lock().map_err(|_| "监听锁错误".to_string())?; + *guard = Some(watcher); + Ok(()) +} + +/// 在系统文件管理器中显示该路径 +#[tauri::command] +pub fn reveal_path(path: String) -> Result<(), String> { + use std::process::Command; + + #[cfg(target_os = "macos")] + let result = Command::new("open").args(["-R", &path]).spawn(); + + #[cfg(target_os = "windows")] + let result = Command::new("explorer") + .arg(format!("/select,{}", path)) + .spawn(); + + #[cfg(target_os = "linux")] + let result = { + let target = Path::new(&path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| Path::new(&path).to_path_buf()); + Command::new("xdg-open").arg(target).spawn() + }; + + result + .map(|_| ()) + .map_err(|e| format!("打开文件管理器失败: {}", e)) +} + #[derive(Serialize)] pub struct TextFileMeta { size_bytes: u64, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bae0444..64b8122 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,6 +3,8 @@ windows_subsystem = "windows" )] +mod ai; +mod ai_history; mod cache; mod config; mod custom_plugin_commands; @@ -20,6 +22,11 @@ mod setup; mod update; mod utils; +use crate::ai::{ai_chat, ai_chat_stream}; +use crate::ai_history::{ + AiHistory, delete_ai_conversation, get_ai_conversation, list_ai_conversations, + save_ai_conversation, +}; use crate::cache::{clear_all_cache, clear_plugins_cache, get_cache_info}; use crate::custom_plugin_commands::{ add_custom_plugin, get_custom_plugins, remove_custom_plugin, save_custom_icon, @@ -36,10 +43,13 @@ use crate::env_providers::{ }; use crate::execution::{ ExecutionHistory, PluginManagerState as ExecutionPluginManagerState, clear_execution_history, - execute_code, get_execution_history, is_execution_running, stop_execution, + execute_code, get_execution_history, get_execution_history_page, is_execution_running, + stop_execution, }; use crate::filesystem::{ - get_text_file_meta, read_directory_tree, read_file_lines, read_file_text, write_file_text, + create_directory, create_file, delete_path, get_text_file_meta, list_files, + read_directory_tree, read_file_lines, read_file_text, rename_path, reveal_path, + watch_directory, write_file_text, }; use crate::plugin::{get_info, get_supported_languages}; use crate::setup::app::get_app_info; @@ -71,7 +81,8 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) - .manage(ExecutionHistory::default()) + .manage(ExecutionHistory::new().expect("failed to initialize execution history database")) + .manage(AiHistory::new().expect("failed to initialize ai history database")) .manage(ExecutionPluginManagerState::new(PluginManager::new())) .manage(EnvironmentManagerState::new(env_manager)) .setup(|app| { @@ -116,6 +127,7 @@ fn main() { stop_execution, is_execution_running, get_execution_history, + get_execution_history_page, clear_execution_history, // 信息相关命令 get_info, @@ -158,7 +170,22 @@ fn main() { read_file_text, write_file_text, get_text_file_meta, - read_file_lines + read_file_lines, + create_file, + create_directory, + rename_path, + delete_path, + reveal_path, + watch_directory, + list_files, + // AI 助手 + ai_chat, + ai_chat_stream, + // AI 对话历史 + save_ai_conversation, + list_ai_conversations, + get_ai_conversation, + delete_ai_conversation ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index deff308..99044cc 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ExecutionResult { pub success: bool, + pub code: String, pub stdout: String, pub stderr: String, pub execution_time: u128, @@ -19,6 +20,14 @@ pub struct ExecutionResult { pub struct CodeExecutionRequest { pub code: String, pub language: String, + // 本次执行的唯一标识,用于事件路由(支持多标签并发运行) + pub task_id: String, + // 关联的本地文件路径;存在则就地运行该文件(工作目录为其所在目录) + pub file_path: Option, + // 传给程序的标准输入 + pub stdin: Option, + // 追加到运行命令后的参数 + pub args: Option>, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4936414..23854b3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,6 +19,7 @@ "height": 1200, "center": true, "devtools": true, + "dragDropEnabled": false, "additionalBrowserArgs": "--disable-context-menu" } ], diff --git a/src/App.vue b/src/App.vue index d9089b2..01caea5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,24 +8,49 @@ :sidebar-visible="sidebarVisible" @toggle-sidebar="toggleSidebar" @run-code="handleRunCode" - @stop-code="() => stopCode(currentLanguage)" + @stop-code="stopCode" @language-change="onLanguageChange" @layout-change="handleLayoutChange" @open-file="handleOpenFileClick" @save-file="saveFile" + @show-history="showHistory = true" + @show-ai="showAi = true" @show-settings="showSettings = true" @load-example="loadExample"> + +
+ +
+
+ + +
+
+ + +
+
+
+