初始提交:识流 AI 助手项目

微信自动回复机器人,基于截图+OCR识别消息,支持关键词规则和 AI(OpenAI/DeepSeek/Dify)自动回复。
技术栈:PySide6 + Flask + Vue3 + RapidOCR + SQLite

注:OCR大模型文件(.onnx / .pdiparams)不纳入版本控制,需单独下载。

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
figmar
2026-05-30 14:57:45 +08:00
commit 81115dc23d
129 changed files with 56398 additions and 0 deletions

View 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);
}
});
}

View 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();
}