初始提交:识流 AI 助手项目
微信自动回复机器人,基于截图+OCR识别消息,支持关键词规则和 AI(OpenAI/DeepSeek/Dify)自动回复。 技术栈:PySide6 + Flask + Vue3 + RapidOCR + SQLite 注:OCR大模型文件(.onnx / .pdiparams)不纳入版本控制,需单独下载。 🤖 Generated with [Qoder][https://qoder.com]
4
frontend/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
4941
frontend/src-tauri/Cargo.lock
generated
Normal file
23
frontend/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.2", features = ["devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
3
frontend/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
17
frontend/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
BIN
frontend/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
frontend/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
frontend/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/src-tauri/icons/icon.icns
Normal file
BIN
frontend/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
0
frontend/src-tauri/resources/.gitkeep
Normal file
356
frontend/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::TcpStream,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Mutex,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tauri::{AppHandle, Manager, RunEvent};
|
||||
|
||||
struct AppState {
|
||||
backend_child: Mutex<Option<Child>>,
|
||||
backend_bootstrap_error: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
struct RuntimePaths {
|
||||
app_data_dir: PathBuf,
|
||||
log_root_dir: PathBuf,
|
||||
runtime_log_dir: PathBuf,
|
||||
bot_log_file: PathBuf,
|
||||
ocr_log_file: PathBuf,
|
||||
ocr_save_dir: PathBuf,
|
||||
blocked_row_cache_file: PathBuf,
|
||||
}
|
||||
|
||||
struct BackendLaunchPlan {
|
||||
program: PathBuf,
|
||||
args: Vec<PathBuf>,
|
||||
working_dir: PathBuf,
|
||||
envs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn project_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.expect("project root not found")
|
||||
}
|
||||
|
||||
fn backend_python() -> PathBuf {
|
||||
project_root().join(".venv").join("Scripts").join("python.exe")
|
||||
}
|
||||
|
||||
fn backend_entry() -> PathBuf {
|
||||
project_root().join("backend_main.py")
|
||||
}
|
||||
|
||||
fn normalize_mode(value: &str) -> String {
|
||||
let mode = value.trim().to_ascii_lowercase();
|
||||
match mode.as_str() {
|
||||
"release" | "prod" | "production" | "bundle" | "packaged" => "release".to_string(),
|
||||
_ => "dev".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_mode() -> String {
|
||||
std::env::var("OPENCLAW_BACKEND_MODE")
|
||||
.map(|value| normalize_mode(&value))
|
||||
.unwrap_or_else(|_| {
|
||||
if cfg!(debug_assertions) {
|
||||
"dev".to_string()
|
||||
} else {
|
||||
"release".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn backend_addr() -> String {
|
||||
std::env::var("APP_HOST")
|
||||
.map(|host| {
|
||||
let port = std::env::var("APP_PORT").unwrap_or_else(|_| "5000".to_string());
|
||||
format!("{}:{}", host, port)
|
||||
})
|
||||
.unwrap_or_else(|_| "127.0.0.1:5000".to_string())
|
||||
}
|
||||
|
||||
fn is_backend_alive() -> bool {
|
||||
TcpStream::connect(backend_addr()).is_ok()
|
||||
}
|
||||
|
||||
fn wait_for_backend(child: &mut Child, timeout: Duration) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < timeout {
|
||||
if is_backend_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(format!("独立后台进程已退出,退出码: {}", status));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
Err(format!("后台启动超时,未监听 {}", backend_addr()))
|
||||
}
|
||||
|
||||
fn ensure_dir(path: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {}", path.display(), err))
|
||||
}
|
||||
|
||||
fn resolve_runtime_paths(app: &AppHandle) -> Result<RuntimePaths, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.map_err(|err| format!("获取应用数据目录失败: {}", err))?;
|
||||
let log_root_dir = app_data_dir.join("logs");
|
||||
let runtime_log_dir = log_root_dir.join("runtime");
|
||||
let bot_log_file = runtime_log_dir.join("bot").join("wechat_multi_chat_bot.log");
|
||||
let ocr_log_file = runtime_log_dir.join("ocr").join("wechat_ocr.log");
|
||||
let ocr_save_dir = log_root_dir.join("ocr_debug_images");
|
||||
let blocked_row_cache_file = log_root_dir.join("state").join("blocked_rows.json");
|
||||
|
||||
ensure_dir(&app_data_dir)?;
|
||||
ensure_dir(&runtime_log_dir.join("bot"))?;
|
||||
ensure_dir(&runtime_log_dir.join("ocr"))?;
|
||||
ensure_dir(&ocr_save_dir)?;
|
||||
ensure_dir(
|
||||
blocked_row_cache_file
|
||||
.parent()
|
||||
.ok_or_else(|| "状态缓存目录无效".to_string())?,
|
||||
)?;
|
||||
|
||||
Ok(RuntimePaths {
|
||||
app_data_dir,
|
||||
log_root_dir,
|
||||
runtime_log_dir,
|
||||
bot_log_file,
|
||||
ocr_log_file,
|
||||
ocr_save_dir,
|
||||
blocked_row_cache_file,
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_envs(app: &AppHandle, mode: &str) -> Result<HashMap<String, String>, String> {
|
||||
let paths = resolve_runtime_paths(app)?;
|
||||
let mut envs = HashMap::new();
|
||||
envs.insert("APP_NAME".to_string(), "AiShiliu".to_string());
|
||||
envs.insert("APP_DATA_DIR".to_string(), paths.app_data_dir.display().to_string());
|
||||
envs.insert("LOG_ROOT_DIR".to_string(), paths.log_root_dir.display().to_string());
|
||||
envs.insert(
|
||||
"RUNTIME_LOG_DIR".to_string(),
|
||||
paths.runtime_log_dir.display().to_string(),
|
||||
);
|
||||
envs.insert("BOT_LOG_FILE".to_string(), paths.bot_log_file.display().to_string());
|
||||
envs.insert("OCR_LOG_FILE".to_string(), paths.ocr_log_file.display().to_string());
|
||||
envs.insert("OCR_SAVE_DIR".to_string(), paths.ocr_save_dir.display().to_string());
|
||||
envs.insert(
|
||||
"BLOCKED_ROW_CACHE_FILE".to_string(),
|
||||
paths.blocked_row_cache_file.display().to_string(),
|
||||
);
|
||||
|
||||
envs.insert("OPENCLAW_BACKEND_MODE".to_string(), mode.to_string());
|
||||
envs.insert("OPENCLAW_RUNTIME_MODE".to_string(), mode.to_string());
|
||||
Ok(envs)
|
||||
}
|
||||
|
||||
fn release_backend_candidates(app: &AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|err| format!("获取资源目录失败: {}", err))?;
|
||||
let exe_dir = std::env::current_exe()
|
||||
.map_err(|err| format!("获取主程序路径失败: {}", err))?
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| "无法解析主程序目录".to_string())?;
|
||||
|
||||
Ok(vec![
|
||||
resource_dir.join("backend").join("backend.exe"),
|
||||
resource_dir.join("backend.exe"),
|
||||
exe_dir.join("resources").join("backend").join("backend.exe"),
|
||||
exe_dir.join("resources").join("backend.exe"),
|
||||
exe_dir.join("backend").join("backend.exe"),
|
||||
exe_dir.join("backend.exe"),
|
||||
])
|
||||
}
|
||||
|
||||
fn release_working_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|err| format!("获取资源目录失败: {}", err))?;
|
||||
Ok(resource_dir)
|
||||
}
|
||||
|
||||
fn resolve_backend_launch_plan(app: &AppHandle) -> Result<BackendLaunchPlan, String> {
|
||||
let mode = backend_mode();
|
||||
let envs = runtime_envs(app, &mode)?;
|
||||
|
||||
if mode == "release" {
|
||||
let candidates = release_backend_candidates(app)?;
|
||||
let program = candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.exists())
|
||||
.ok_or_else(|| "未找到发布态后端程序 backend.exe".to_string())?;
|
||||
let working_dir = release_working_dir(app)?;
|
||||
return Ok(BackendLaunchPlan {
|
||||
program,
|
||||
args: Vec::new(),
|
||||
working_dir,
|
||||
envs,
|
||||
});
|
||||
}
|
||||
|
||||
let python = backend_python();
|
||||
if !python.exists() {
|
||||
return Err(format!("未找到后端解释器: {}", python.display()));
|
||||
}
|
||||
let entry = backend_entry();
|
||||
if !entry.exists() {
|
||||
return Err(format!("未找到后端入口: {}", entry.display()));
|
||||
}
|
||||
Ok(BackendLaunchPlan {
|
||||
program: python,
|
||||
args: vec![entry],
|
||||
working_dir: project_root(),
|
||||
envs,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_backend(app: &AppHandle) -> Result<(), String> {
|
||||
if is_backend_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let plan = resolve_backend_launch_plan(app)?;
|
||||
let mut command = Command::new(&plan.program);
|
||||
command.current_dir(&plan.working_dir);
|
||||
for arg in &plan.args {
|
||||
command.arg(arg);
|
||||
}
|
||||
for (key, value) in &plan.envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
command
|
||||
.env("OPENCLAW_PROJECT_VENV_REEXEC", "1")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|err| format!("启动后台失败: {}", err))?;
|
||||
if let Err(err) = wait_for_backend(&mut child, Duration::from_secs(20)) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(err);
|
||||
}
|
||||
let state = app.state::<AppState>();
|
||||
let mut guard = state
|
||||
.backend_child
|
||||
.lock()
|
||||
.map_err(|_| "后台状态锁定失败".to_string())?;
|
||||
*guard = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ps_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn cleanup_orphan_backend_processes(app: &AppHandle, mode: &str) {
|
||||
let script = if mode == "release" {
|
||||
let targets = release_backend_candidates(app)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| ps_single_quote(&path.display().to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
format!("$targets=@({}); Get-CimInstance Win32_Process | Where-Object {{ $_.ExecutablePath -and ($targets -contains $_.ExecutablePath) }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}", targets)
|
||||
} else {
|
||||
"Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq 'python.exe' -and $_.CommandLine -match 'backend_main.py' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }".to_string()
|
||||
};
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-Command")
|
||||
.arg(script)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
fn terminate_backend(app: &AppHandle) {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
if let Ok(mut guard) = state.backend_child.lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup_orphan_backend_processes(app, &backend_mode());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_devtools(app: AppHandle) -> Result<(), String> {
|
||||
let webview = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "未找到主窗口".to_string())?;
|
||||
webview.open_devtools();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_backend_bootstrap_error(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let guard = state
|
||||
.backend_bootstrap_error
|
||||
.lock()
|
||||
.map_err(|_| "后台启动错误状态锁定失败".to_string())?;
|
||||
Ok(guard.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_backend(app: AppHandle) -> Result<(), String> {
|
||||
terminate_backend(&app);
|
||||
spawn_backend(&app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
backend_child: Mutex::new(None),
|
||||
backend_bootstrap_error: Mutex::new(None),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![open_devtools, get_backend_bootstrap_error, restart_backend])
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
if let Err(err) = spawn_backend(app.handle()) {
|
||||
if let Ok(mut guard) = app.state::<AppState>().backend_bootstrap_error.lock() {
|
||||
*guard = Some(err.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
|
||||
terminate_backend(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
6
frontend/src-tauri/src/main.rs
Normal file
@@ -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() {
|
||||
app_lib::run();
|
||||
}
|
||||
51
frontend/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "识流 AI 助手",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.shiliu.aiassistant",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "识流 AI 助手",
|
||||
"width": 1280,
|
||||
"height": 920,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 760,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": [
|
||||
"resources/**/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"android": {
|
||||
"debugApplicationIdSuffix": ".debug"
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"language": "zh-CN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||