初始提交:识流 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
+1
View File
@@ -0,0 +1 @@
function t(n,e,i,s){if(typeof e=="function"?n!==e||!s:!e.has(n))throw new TypeError("Cannot read private member from an object whose class did not declare it");return i==="m"?s:i==="a"?s.call(n):s?s.value:e.get(n)}function c(n,e,i,s,_){if(typeof e=="function"?n!==e||!0:!e.has(n))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(n,i),i}var a,r,o,l,u;const d="__TAURI_TO_IPC_KEY__";function w(n,e=!1){return window.__TAURI_INTERNALS__.transformCallback(n,e)}class g{constructor(e){a.set(this,void 0),r.set(this,0),o.set(this,[]),l.set(this,void 0),c(this,a,e||(()=>{})),this.id=w(i=>{const s=i.index;if("end"in i){s==t(this,r,"f")?this.cleanupCallback():c(this,l,s);return}const _=i.message;if(s==t(this,r,"f")){for(t(this,a,"f").call(this,_),c(this,r,t(this,r,"f")+1);t(this,r,"f")in t(this,o,"f");){const p=t(this,o,"f")[t(this,r,"f")];t(this,a,"f").call(this,p),delete t(this,o,"f")[t(this,r,"f")],c(this,r,t(this,r,"f")+1)}t(this,r,"f")===t(this,l,"f")&&this.cleanupCallback()}else t(this,o,"f")[s]=_})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(e){c(this,a,e)}get onmessage(){return t(this,a,"f")}[(a=new WeakMap,r=new WeakMap,o=new WeakMap,l=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}class f{constructor(e,i,s){this.plugin=e,this.event=i,this.channelId=s}async unregister(){return h(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function m(n,e,i){const s=new g(i);try{return await h(`plugin:${n}|register_listener`,{event:e,handler:s}),new f(n,e,s.id)}catch{return await h(`plugin:${n}|registerListener`,{event:e,handler:s}),new f(n,e,s.id)}}async function I(n){return h(`plugin:${n}|check_permissions`)}async function C(n){return h(`plugin:${n}|request_permissions`)}async function h(n,e={},i){return window.__TAURI_INTERNALS__.invoke(n,e,i)}function T(n,e="asset"){return window.__TAURI_INTERNALS__.convertFileSrc(n,e)}class k{get rid(){return t(this,u,"f")}constructor(e){u.set(this,void 0),c(this,u,e)}async close(){return h("plugin:resources|close",{rid:this.rid})}}u=new WeakMap;function E(){return!!(globalThis||window).isTauri}export{g as Channel,f as PluginListener,k as Resource,d as SERIALIZE_TO_IPC_FN,m as addPluginListener,I as checkPermissions,T as convertFileSrc,h as invoke,E as isTauri,C as requestPermissions,w as transformCallback};
+1
View File
@@ -0,0 +1 @@
:root{--bg: #f2f4f7;--panel: #ffffff;--line: #e5e7eb;--text: #111827;--muted: #6b7280;--brand: #22c1c3}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:linear-gradient(180deg,#f5f7fb,#eef2f7);color:var(--text);font-family:Inter,PingFang SC,Microsoft YaHei,sans-serif;overflow:hidden}button,input,select,textarea{font:inherit}.page-shell{height:100vh;overflow:hidden}.content-root{height:calc(100vh - 48px);overflow:hidden;display:flex;flex-direction:column}.content-scroll{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}.topbar{height:48px;background:#ffffffeb;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);position:sticky;top:0;z-index:20;-webkit-user-select:none;user-select:none}.drag-region{height:100%;width:100%;display:flex;align-items:center;justify-content:space-between;cursor:default}.inner{width:min(1320px,calc(100vw - 24px));margin:0 auto}.window-actions{display:flex;align-items:center;gap:4px}.win-btn{width:32px;height:28px;border-radius:8px;border:1px solid #e7ecf2;background:#fff;color:#4b5563;display:inline-flex;align-items:center;justify-content:center;font-size:14px;line-height:1;cursor:pointer}.win-btn:hover{background:#f5f7fb}.win-btn.close:hover{background:#fee2e2;color:#991b1b;border-color:#fecaca}.panel{background:var(--panel);border:1px solid #edf0f4;border-radius:14px;box-shadow:0 6px 18px #0f172a0a}.hero{background:linear-gradient(120deg,#22c1c326,#7c3aed14);border:1px solid #dff4f5}.tab-wrap{flex:1;min-height:0;display:flex;flex-direction:column;background:#fff;border:1px solid #e8ecf2;border-radius:12px;padding:8px 12px 0;box-shadow:0 6px 14px #0f172a08;overflow:hidden}.app-tabs,.app-tabs .n-tabs-nav-scroll-content,.app-tabs .n-tabs-pane-wrapper,.app-tabs .n-tab-pane,.app-tab-pane{min-height:0}.app-tabs{flex:1;min-height:0;display:flex;flex-direction:column}.app-tabs .n-tabs-nav{flex:0 0 auto}.app-tabs .n-tabs-pane-wrapper{flex:1;min-height:0}.app-tabs .n-tab-pane,.app-tab-pane{height:100%;display:flex;flex-direction:column}.app-tab-pane>*{flex:1;min-height:0}.label-title{font-weight:600;font-size:14px;color:#1f2937}.label-desc{font-size:12px;color:#6b7280;margin-top:2px}.row-line{border-bottom:1px dashed #edf1f5;padding:14px 0}.row-line:last-child{border-bottom:0}.compact-card .n-card-header{padding-bottom:8px!important}.compact-card .n-card__content{padding-top:0!important}.panel-view-shell{flex:1;min-height:0;display:flex;flex-direction:column;gap:16px}.logs-view-shell{flex:1;min-height:0;padding:12px 0}.log-panel,.log-panel .n-card__content{flex:1;min-height:0;height:100%;display:flex;flex-direction:column}.log-panel .n-card-header{flex:0 0 auto}.log-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.log-toolbar-right{display:flex;align-items:center;gap:12px;min-width:0}.log-path{color:#6b7280;font-size:12px;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.log-stream-wrap{height:clamp(320px,calc(100vh - 320px),680px);min-height:320px;max-height:680px;border:1px solid #e6ebf2;border-radius:12px;background:#0f172a;overflow-y:auto;overflow-x:hidden}.log-stream{margin:0;padding:14px 16px;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;font-family:Consolas,SFMono-Regular,Monaco,monospace}.log-line{white-space:pre-wrap}.log-line--error{color:#fca5a5}.log-line--warning{color:#fde68a}.log-line--info{color:#bfdbfe}.log-line--debug{color:#cbd5e1}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>识流 AI 助手</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" crossorigin src="/assets/index-C4rM3269.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Avv0D5es.css">
</head>
<body>
<div id="app"></div>
</body>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>识流 AI 助手</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1817
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ai-shiliu-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"backend:build": "scripts\\backend-build.bat",
"release:prepare": "npm run build && npm run backend:build",
"tauri:dev": "tauri dev",
"tauri:build": "npm run release:prepare && tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"naive-ui": "^2.43.1",
"vue": "^3.5.22"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.2",
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.5"
}
}
+49
View File
@@ -0,0 +1,49 @@
@echo off
setlocal enabledelayedexpansion
set "ROOT=%~dp0..\.."
for %%I in ("%ROOT%") do set "ROOT=%%~fI"
set "PYTHON=%ROOT%\.venv\Scripts\python.exe"
if not exist "%PYTHON%" (
echo Missing .venv\Scripts\python.exe
exit /b 1
)
"%PYTHON%" -m pip show rapidocr-onnxruntime >nul 2>nul
set "HAS_RAPIDOCR=%ERRORLEVEL%"
"%PYTHON%" -m pip show onnxruntime >nul 2>nul
set "HAS_ONNXRUNTIME=%ERRORLEVEL%"
if not "%HAS_RAPIDOCR%"=="0" goto install_ocr
if not "%HAS_ONNXRUNTIME%"=="0" goto install_ocr
goto after_install
:install_ocr
"%PYTHON%" -m pip install -r "%ROOT%\requirements.txt"
if errorlevel 1 exit /b 1
:after_install
"%PYTHON%" -m PyInstaller --version >nul
if errorlevel 1 exit /b 1
set "RESOURCES=%ROOT%\frontend\src-tauri\resources"
set "BACKEND_OUT=%RESOURCES%\backend"
set "BUILD_ROOT=%ROOT%\.build\pyinstaller"
set "STALE_ROOT=%ROOT%\.build\stale-resources"
if exist "%BACKEND_OUT%" (
rmdir /s /q "%BACKEND_OUT%" 2>nul
if exist "%BACKEND_OUT%" (
if not exist "%STALE_ROOT%" mkdir "%STALE_ROOT%"
set "STALE_NAME=backend-%DATE:~0,4%%DATE:~5,2%%DATE:~8,2%%TIME:~0,2%%TIME:~3,2%%TIME:~6,2%"
set "STALE_NAME=!STALE_NAME: =0!"
move "%BACKEND_OUT%" "%STALE_ROOT%\!STALE_NAME!" >nul
)
)
if not exist "%RESOURCES%" mkdir "%RESOURCES%"
"%PYTHON%" -m PyInstaller --noconfirm --clean --onedir --name backend --distpath "%RESOURCES%" --workpath "%BUILD_ROOT%\work" --specpath "%BUILD_ROOT%\spec" --paths "%ROOT%" --collect-all rapidocr_onnxruntime --collect-all onnxruntime --copy-metadata rapidocr-onnxruntime --copy-metadata onnxruntime --add-data "%ROOT%\app;app" "%ROOT%\backend_main.py"
if errorlevel 1 exit /b 1
exit /b 0
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4941
View File
File diff suppressed because it is too large Load Diff
+23
View 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
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+356
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);
}
});
}
+6
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();
}
+51
View 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"
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
NConfigProvider,
NLayout,
NLayoutContent,
NLayoutHeader,
NTabPane,
NTabs,
} from 'naive-ui'
import AppTopBar from './components/AppTopBar.vue'
import { messageColumns } from './constants/messageColumns'
import { useBotStatus } from './composables/useBotStatus'
import { useMessages } from './composables/useMessages'
import { useSettings } from './composables/useSettings'
import { useWindowBridge } from './composables/useWindowBridge'
import MessagesView from './views/MessagesView.vue'
import PanelView from './views/PanelView.vue'
import RulesView from './views/RulesView.vue'
import LogsView from './views/LogsView.vue'
const message = window.$message
const activeTab = ref('panel')
const settingsTimer = ref(null)
const messagesTimer = ref(null)
const rulesViewRef = ref(null)
const { initWindowBridge, startMove, minimizeWindow, maximizeOrRestore, closeWindow, openDevtools, restartApp } = useWindowBridge()
const { settings, loadSettings, saveSettings } = useSettings(message)
const { loadingMessages, messages, loadMessages } = useMessages(message)
const {
runtimeText,
runtimeTagType,
refreshBotStatus,
connectBotStatusStream,
startStatusHealthCheck,
onListenerChange,
closeStatusStream,
applyBotStatus,
} = useBotStatus(settings, () => loadSettings(applyBotStatus), message)
function clearPageTimers() {
if (settingsTimer.value) {
clearInterval(settingsTimer.value)
settingsTimer.value = null
}
if (messagesTimer.value) {
clearInterval(messagesTimer.value)
messagesTimer.value = null
}
}
async function checkBackendBootstrapError() {
if (!window.__TAURI_INTERNALS__) return
try {
const { invoke } = await import('@tauri-apps/api/core')
const err = await invoke('get_backend_bootstrap_error')
if (err) {
message.error(`后端启动失败:${err}`)
}
} catch {
}
}
function onTabChange(value) {
activeTab.value = value
if (value === 'rules') {
rulesViewRef.value?.loadRules?.()
}
if (value === 'messages') {
loadMessages()
}
}
onMounted(async () => {
initWindowBridge()
await checkBackendBootstrapError()
await loadSettings(applyBotStatus)
await refreshBotStatus().catch(() => {})
connectBotStatusStream()
startStatusHealthCheck()
await loadMessages()
settingsTimer.value = setInterval(() => loadSettings(applyBotStatus), 30000)
messagesTimer.value = setInterval(() => {
if (activeTab.value === 'messages') loadMessages()
}, 5000)
})
onBeforeUnmount(() => {
clearPageTimers()
closeStatusStream()
})
</script>
<template>
<n-config-provider :theme-overrides="{
common: { primaryColor: '#22c1c3', primaryColorHover: '#1ba8aa', primaryColorPressed: '#169295', borderRadius: '10px' }
}">
<n-layout class="page-shell">
<n-layout-header class="topbar">
<AppTopBar
:runtime-text="runtimeText"
:runtime-tag-type="runtimeTagType"
:on-start-move="startMove"
:on-minimize="minimizeWindow"
:on-maximize-or-restore="maximizeOrRestore"
:on-close="closeWindow"
:on-open-devtools="openDevtools"
:on-restart-app="restartApp"
/>
</n-layout-header>
<n-layout-content class="content-root" content-style="padding:16px 0 16px;height:100%;overflow:hidden;">
<div class="inner content-scroll">
<div class="panel hero p-4 mb-3">
<div class="text-[15px] font-semibold text-slate-800 mb-1">管理后台</div>
<div class="text-[13px] text-slate-600">组件化管理页面已移除系统原生窗口栏</div>
</div>
<div class="tab-wrap">
<n-tabs class="app-tabs" type="segment" animated :value="activeTab" @update:value="onTabChange">
<n-tab-pane class="app-tab-pane" name="panel" tab="面板">
<PanelView
:settings="settings"
:on-listener-change="onListenerChange"
:on-save-settings="payload => saveSettings(payload, applyBotStatus)"
/>
</n-tab-pane>
<n-tab-pane class="app-tab-pane" name="rules" tab="回复匹配配置">
<RulesView ref="rulesViewRef" :message="message" />
</n-tab-pane>
<n-tab-pane class="app-tab-pane" name="logs" tab="运行日志">
<LogsView />
</n-tab-pane>
<n-tab-pane class="app-tab-pane" name="messages" tab="历史消息">
<MessagesView :columns="messageColumns" :messages="messages" :loading="loadingMessages" :on-refresh="loadMessages" />
</n-tab-pane>
</n-tabs>
</div>
</div>
</n-layout-content>
</n-layout>
</n-config-provider>
</template>
+43
View File
@@ -0,0 +1,43 @@
import { getJson, postForm, postJson } from './http'
export function getBotStatus() {
return getJson('/api/bot/status')
}
export function getLogEvents(params = {}) {
const query = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
query.set(key, String(value))
})
const suffix = query.toString() ? `?${query.toString()}` : ''
return getJson(`/api/logs/v2/events${suffix}`)
}
export function getLogSummary(limit = 300) {
return getJson(`/api/logs/v2/summary?limit=${encodeURIComponent(limit)}`)
}
export function getLogTrace(traceId) {
return getJson(`/api/logs/v2/trace/${encodeURIComponent(traceId)}`)
}
export function getBotLogs(kind = 'bot', limit = 80) {
return getLogEvents({ module: kind, size: limit, page: 1 })
}
export function getLogEventJson(eventId) {
return getJson(`/api/logs/v2/event/${encodeURIComponent(eventId)}`)
}
export function clearLogs(module = '') {
return postJson('/api/logs/v2/clear', { module })
}
export function startBot() {
return postForm('/api/bot/start', {})
}
export function stopBot() {
return postForm('/api/bot/stop', {})
}
+1
View File
@@ -0,0 +1 @@
export { getJson, postForm } from './http'
+57
View File
@@ -0,0 +1,57 @@
const DEFAULT_BACKEND_ORIGIN = 'http://127.0.0.1:5000'
function normalizePath(path) {
if (!path) return '/'
return path.startsWith('/') ? path : `/${path}`
}
function isAbsoluteUrl(url) {
return /^https?:\/\//i.test(String(url || ''))
}
function getConfiguredBackendOrigin() {
const raw = String(window.__APP_BACKEND_URL || '').trim()
if (!raw) return ''
return raw.endsWith('/') ? raw.slice(0, -1) : raw
}
export function isTauriRuntime() {
return !!window.__TAURI_INTERNALS__
}
export function resolveApiUrl(path) {
if (isAbsoluteUrl(path)) return path
const normalizedPath = normalizePath(path)
const backendOrigin = getConfiguredBackendOrigin()
if (backendOrigin) return `${backendOrigin}${normalizedPath}`
if (isTauriRuntime()) return `${DEFAULT_BACKEND_ORIGIN}${normalizedPath}`
return normalizedPath
}
export async function getJson(url) {
const resp = await fetch(resolveApiUrl(url))
return resp.json()
}
export async function postForm(url, payload) {
const body = new URLSearchParams(payload)
const resp = await fetch(resolveApiUrl(url), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
return resp.json()
}
export async function postJson(url, payload) {
const resp = await fetch(resolveApiUrl(url), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {}),
})
return resp.json()
}
export function createEventSource(url) {
return new EventSource(resolveApiUrl(url))
}
+5
View File
@@ -0,0 +1,5 @@
import { getJson } from './http'
export function getRecentMessages(limit = 50) {
return getJson(`/api/rules?action=messages_recent&limit=${limit}`)
}
+17
View File
@@ -0,0 +1,17 @@
import { getJson, postForm } from './http'
export function getRules() {
return getJson('/api/rules?action=list')
}
export function createRule(payload) {
return postForm('/api/rules', { action: 'create', ...payload })
}
export function toggleRule(payload) {
return postForm('/api/rules', { action: 'toggle', ...payload })
}
export function deleteRule(id) {
return postForm('/api/rules', { action: 'delete', id })
}
+9
View File
@@ -0,0 +1,9 @@
import { getJson, postForm } from './http'
export function getSettings() {
return getJson('/api/rules?action=settings_get')
}
export function saveSettings(payload) {
return postForm('/api/rules', { action: 'settings_set', ...payload })
}
+55
View File
@@ -0,0 +1,55 @@
<script setup>
import RuntimeBadge from './RuntimeBadge.vue'
defineProps({
runtimeText: {
type: String,
default: '',
},
runtimeTagType: {
type: String,
default: 'default',
},
onStartMove: {
type: Function,
required: true,
},
onMinimize: {
type: Function,
required: true,
},
onMaximizeOrRestore: {
type: Function,
required: true,
},
onClose: {
type: Function,
required: true,
},
onOpenDevtools: {
type: Function,
required: true,
},
onRestartApp: {
type: Function,
required: true,
},
})
</script>
<template>
<div class="inner drag-region" data-tauri-drag-region @mousedown="onStartMove" @dblclick="onMaximizeOrRestore">
<div class="flex items-center gap-3" data-tauri-drag-region>
<div class="w-8 h-8 rounded-lg bg-cyan-100 text-cyan-700 flex items-center justify-center font-bold"></div>
<div class="text-[16px] font-semibold text-slate-800">识流 AI 助手</div>
<RuntimeBadge :type="runtimeTagType" :text="runtimeText" />
</div>
<div class="window-actions" @mousedown.stop>
<button class="win-btn" type="button" @click.stop="onOpenDevtools"></button>
<button class="win-btn" type="button" @click.stop="onRestartApp"></button>
<button class="win-btn" type="button" @click.stop="onMinimize"></button>
<button class="win-btn" type="button" @click.stop="onMaximizeOrRestore"></button>
<button class="win-btn close" type="button" @click.stop="onClose"></button>
</div>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup>
import { NTag } from 'naive-ui'
defineProps({
type: {
type: String,
default: 'default',
},
text: {
type: String,
default: '',
},
})
</script>
<template>
<n-tag :type="type" :bordered="false" round>{{ text }}</n-tag>
</template>
+177
View File
@@ -0,0 +1,177 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { getBotStatus, startBot, stopBot } from '../api/bot'
import { createEventSource } from '../api/http'
export function useBotStatus(settings, loadSettings, message) {
const statusEventSource = ref(null)
const statusFallbackTimer = ref(null)
const statusHealthTimer = ref(null)
const statusConnecting = ref(false)
const lastStatusStreamAt = ref(0)
const runtimeText = computed(() => {
if (settings.listener_runtime_status === 'running') return '运行中'
if (settings.listener_runtime_status === 'starting') return '启动中'
if (settings.listener_runtime_status === 'stopping') return '停止中'
if (settings.listener_runtime_status === 'error') return '异常'
return '已停止'
})
const runtimeTagType = computed(() => {
if (settings.listener_runtime_status === 'running') return 'success'
if (settings.listener_runtime_status === 'starting' || settings.listener_runtime_status === 'stopping') return 'warning'
if (settings.listener_runtime_status === 'error') return 'error'
return 'default'
})
function applyBotStatus(bot, fallbackEnabled) {
const nextStatus = String(bot?.status || 'stopped').toLowerCase()
settings.listener_runtime_status = nextStatus
if (nextStatus === 'running' || nextStatus === 'starting') settings.listener_enabled = true
else if (nextStatus === 'stopped' || nextStatus === 'error') settings.listener_enabled = false
else settings.listener_enabled = !!fallbackEnabled
if (nextStatus === 'error') {
const detail = String(bot?.last_error || '').trim().split('\n').slice(-1)[0] || ''
if (detail && detail !== lastNotifiedError.value) {
lastNotifiedError.value = detail
message.error(`监听启动异常: ${detail}`)
} else if (!detail && lastNotifiedError.value !== '__unknown__') {
lastNotifiedError.value = '__unknown__'
message.error('监听启动异常,请检查微信环境、日志与配置')
}
}
}
async function refreshBotStatus() {
const bot = await getBotStatus()
if (bot.success) applyBotStatus(bot, settings.listener_enabled)
}
function touchStatusStream() {
lastStatusStreamAt.value = Date.now()
}
function clearStatusFallbackTimer() {
if (statusFallbackTimer.value) {
clearInterval(statusFallbackTimer.value)
statusFallbackTimer.value = null
}
}
function clearStatusHealthTimer() {
if (statusHealthTimer.value) {
clearInterval(statusHealthTimer.value)
statusHealthTimer.value = null
}
}
function closeStatusStream({ reconnect = false } = {}) {
clearStatusFallbackTimer()
clearStatusHealthTimer()
if (statusEventSource.value) {
const source = statusEventSource.value
statusEventSource.value = null
source.onopen = null
source.onerror = null
source.close()
}
statusConnecting.value = false
if (reconnect) {
connectBotStatusStream(true)
}
}
function ensureStatusFallbackPolling() {
if (statusFallbackTimer.value) return
statusFallbackTimer.value = setInterval(() => {
refreshBotStatus().catch(() => {})
}, 15000)
}
function startStatusHealthCheck() {
if (statusHealthTimer.value) return
statusHealthTimer.value = setInterval(() => {
const lastAt = lastStatusStreamAt.value || 0
if (!statusEventSource.value) {
connectBotStatusStream()
return
}
if (lastAt && Date.now() - lastAt <= 45000) {
return
}
ensureStatusFallbackPolling()
closeStatusStream({ reconnect: true })
}, 10000)
}
function connectBotStatusStream(force = false) {
if (statusConnecting.value) return
if (statusEventSource.value && !force) return
if (statusEventSource.value && force) {
closeStatusStream()
}
statusConnecting.value = true
try {
const source = createEventSource('/api/bot/status/stream')
statusEventSource.value = source
source.addEventListener('status', (event) => {
touchStatusStream()
try {
const payload = JSON.parse(event.data || '{}')
if (payload.success) {
applyBotStatus(payload, settings.listener_enabled)
}
clearStatusFallbackTimer()
} catch (_) {
}
})
source.addEventListener('ping', () => {
touchStatusStream()
clearStatusFallbackTimer()
})
source.onopen = () => {
statusConnecting.value = false
touchStatusStream()
clearStatusFallbackTimer()
}
source.onerror = () => {
statusConnecting.value = false
ensureStatusFallbackPolling()
}
} catch (_) {
statusConnecting.value = false
ensureStatusFallbackPolling()
}
}
async function onListenerChange(value) {
try {
settings.listener_enabled = !!value
settings.listener_runtime_status = value ? 'starting' : 'stopping'
const data = await (value ? startBot() : stopBot())
if (!data.success) throw new Error(data.error || '操作失败')
settings.listener_runtime_status = data.status || (value ? 'running' : 'stopped')
settings.listener_enabled = data.status === 'running' || data.status === 'starting'
await loadSettings()
} catch (error) {
message.error(`监听切换失败: ${error}`)
await loadSettings()
}
}
onBeforeUnmount(() => {
closeStatusStream()
})
return {
runtimeText,
runtimeTagType,
refreshBotStatus,
connectBotStatusStream,
startStatusHealthCheck,
onListenerChange,
closeStatusStream,
applyBotStatus,
}
}
+25
View File
@@ -0,0 +1,25 @@
import { ref } from 'vue'
import { getRecentMessages } from '../api/messages'
export function useMessages(message) {
const loadingMessages = ref(false)
const messages = ref([])
async function loadMessages(limit = 50) {
loadingMessages.value = true
try {
const data = await getRecentMessages(limit)
messages.value = data.data || []
} catch (error) {
message.error(`加载消息失败: ${error}`)
} finally {
loadingMessages.value = false
}
}
return {
loadingMessages,
messages,
loadMessages,
}
}
+132
View File
@@ -0,0 +1,132 @@
import { computed, h, reactive, ref } from 'vue'
import { NButton, NSpace, NTag } from 'naive-ui'
import { createRule, deleteRule, getRules, toggleRule } from '../api/rules'
export function useRulesManager(message) {
const loadingRules = ref(false)
const creatingRule = ref(false)
const showCreateRuleModal = ref(false)
const rules = ref([])
const filters = reactive({ keyword: '', match_type: '', status: '' })
const createForm = reactive({ keyword: '', match_type: 'contain', reply_text: '' })
const filteredRules = computed(() => {
return rules.value.filter((row) => {
if (filters.keyword && !String(row.keyword || '').toLowerCase().includes(filters.keyword.toLowerCase())) return false
if (filters.match_type && row.match_type !== filters.match_type) return false
if (filters.status !== '' && Number(row.is_active) !== Number(filters.status)) return false
return true
})
})
async function loadRules() {
loadingRules.value = true
try {
const data = await getRules()
rules.value = data.data || []
} catch (error) {
message.error(`加载规则失败: ${error}`)
} finally {
loadingRules.value = false
}
}
async function submitCreateRule() {
if (!createForm.keyword.trim() || !createForm.reply_text.trim()) {
message.warning('请填写关键词和回复内容')
return
}
creatingRule.value = true
try {
const data = await createRule({
keyword: createForm.keyword.trim(),
match_type: createForm.match_type,
reply_text: createForm.reply_text.trim(),
})
if (!data.success) {
message.error(data.message || '新增失败')
return
}
message.success('新增成功')
createForm.keyword = ''
createForm.reply_text = ''
showCreateRuleModal.value = false
await loadRules()
} catch (error) {
message.error(`新增失败: ${error}`)
} finally {
creatingRule.value = false
}
}
async function handleToggleRule(row) {
try {
const data = await toggleRule({
id: String(row.id),
is_active: Number(row.is_active) === 1 ? '0' : '1',
})
if (!data.success) {
message.error(data.message || '切换失败')
return
}
await loadRules()
} catch (error) {
message.error(`切换失败: ${error}`)
}
}
async function handleDeleteRule(row) {
try {
const data = await deleteRule(String(row.id))
if (!data.success) {
message.error(data.message || '删除失败')
return
}
message.success('删除成功')
await loadRules()
} catch (error) {
message.error(`删除失败: ${error}`)
}
}
function resetFilters() {
filters.keyword = ''
filters.match_type = ''
filters.status = ''
loadRules()
}
const ruleColumns = [
{ title: '关键词', key: 'keyword', minWidth: 140 },
{ title: '匹配方式', key: 'match_type', width: 120, render: (row) => row.match_type === 'equal' ? '完全匹配' : '包含' },
{ title: '回复内容', key: 'reply_text', minWidth: 220 },
{
title: '状态', key: 'is_active', width: 110,
render: (row) => h(NTag, { type: Number(row.is_active) === 1 ? 'success' : 'default', bordered: false, round: true }, {
default: () => Number(row.is_active) === 1 ? '启用' : '暂停',
}),
},
{
title: '操作', key: 'actions', width: 190,
render: (row) => h(NSpace, { size: 8 }, {
default: () => [
h(NButton, { size: 'small', secondary: true, type: Number(row.is_active) === 1 ? 'warning' : 'success', onClick: () => handleToggleRule(row) }, { default: () => Number(row.is_active) === 1 ? '停用' : '启用' }),
h(NButton, { size: 'small', secondary: true, type: 'error', onClick: () => handleDeleteRule(row) }, { default: () => '删除' }),
],
}),
},
]
return {
loadingRules,
creatingRule,
showCreateRuleModal,
filters,
createForm,
filteredRules,
ruleColumns,
loadRules,
submitCreateRule,
resetFilters,
}
}
+44
View File
@@ -0,0 +1,44 @@
import { reactive } from 'vue'
import { getSettings, saveSettings as saveSettingsApi } from '../api/settings'
export function useSettings(message) {
const settings = reactive({
auto_reply_enabled: false,
listener_enabled: false,
listener_runtime_status: 'stopped',
full_auto_reply_enabled: false,
reply_fallback_mode: 'ai',
})
async function loadSettings(applyBotStatus) {
try {
const data = await getSettings()
if (!data.success) return
settings.auto_reply_enabled = !!data.auto_reply_enabled
settings.full_auto_reply_enabled = !!data.full_auto_reply_enabled
settings.reply_fallback_mode = data.reply_fallback_mode || 'ai'
applyBotStatus({ status: data.listener_runtime_status || 'stopped' }, data.listener_enabled)
} catch (error) {
message.error(`加载设置失败: ${error}`)
}
}
async function saveSettings(payload, applyBotStatus) {
try {
const data = await saveSettingsApi(payload)
if (!data.success) {
message.error(data.message || '保存失败')
return
}
await loadSettings(applyBotStatus)
} catch (error) {
message.error(`保存失败: ${error}`)
}
}
return {
settings,
loadSettings,
saveSettings,
}
}
@@ -0,0 +1,94 @@
import { ref } from 'vue'
let tauriWindow = null
let tauriCore = null
async function ensureTauriModules() {
if (tauriWindow && tauriCore) return true
if (!window.__TAURI_INTERNALS__) return false
try {
const [{ getCurrentWindow }, { invoke }] = await Promise.all([
import('@tauri-apps/api/window'),
import('@tauri-apps/api/core'),
])
tauriWindow = getCurrentWindow()
tauriCore = { invoke }
return true
} catch {
return false
}
}
export function useWindowBridge() {
const windowBridge = ref(null)
const bridgeMode = ref('browser')
async function initWindowBridge() {
if (window.qt && window.QWebChannel) {
bridgeMode.value = 'qt'
new window.QWebChannel(window.qt.webChannelTransport, (channel) => {
windowBridge.value = channel.objects.windowBridge || null
})
return
}
if (await ensureTauriModules()) {
bridgeMode.value = 'tauri'
return
}
bridgeMode.value = 'browser'
}
async function invoke(methodName) {
const bridge = windowBridge.value
if (bridge && typeof bridge[methodName] === 'function') {
bridge[methodName]()
return
}
if (!(await ensureTauriModules())) return
if (methodName === 'start_move') {
await tauriWindow.startDragging()
return
}
if (methodName === 'minimize') {
await tauriWindow.minimize()
return
}
if (methodName === 'maximize_or_restore') {
const maximized = await tauriWindow.isMaximized()
if (maximized) {
await tauriWindow.unmaximize()
} else {
await tauriWindow.maximize()
}
return
}
if (methodName === 'close_window') {
await tauriWindow.close()
return
}
if (methodName === 'open_devtools') {
try {
await tauriCore.invoke('open_devtools')
} catch {
}
return
}
if (methodName === 'restart_app') {
try {
await tauriCore.invoke('restart_backend')
} catch {
}
}
}
return {
bridgeMode,
initWindowBridge,
startMove: () => invoke('start_move'),
minimizeWindow: () => invoke('minimize'),
maximizeOrRestore: () => invoke('maximize_or_restore'),
closeWindow: () => invoke('close_window'),
openDevtools: () => invoke('open_devtools'),
restartApp: () => invoke('restart_app'),
}
}
+10
View File
@@ -0,0 +1,10 @@
import { formatDateTime } from '../utils/datetime'
export const messageColumns = [
{ title: '时间', key: 'created_at', width: 180, render: (row) => formatDateTime(row.created_at) },
{ title: '微信用户', key: 'wx_nickname', width: 180 },
{ title: '方向', key: 'direction', width: 90, render: (row) => row.direction === 'out' ? '发送' : '接收' },
{ title: '内容', key: 'content', minWidth: 320 },
{ title: '气泡方向', key: 'ocr_bubble_side', width: 100 },
{ title: '置信度', key: 'ocr_confidence', width: 90 },
]
+21
View File
@@ -0,0 +1,21 @@
export const fallbackModeOptions = [
{ label: 'AI兜底回复', value: 'ai' },
{ label: '不回复', value: 'none' },
]
export const matchTypeOptions = [
{ label: '包含', value: 'contain' },
{ label: '完全匹配', value: 'equal' },
]
export const matchTypeFilterOptions = [
{ label: '全部匹配方式', value: '' },
{ label: '包含', value: 'contain' },
{ label: '完全匹配', value: 'equal' },
]
export const ruleStatusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 1 },
{ label: '暂停', value: 0 },
]
+33
View File
@@ -0,0 +1,33 @@
import { createApp } from 'vue'
import { createDiscreteApi } from 'naive-ui'
import App from './App.vue'
import './styles.css'
function loadQtWebChannelScript() {
return new Promise((resolve, reject) => {
if (window.QWebChannel) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'qrc:///qtwebchannel/qwebchannel.js'
script.onload = () => resolve()
script.onerror = reject
document.head.appendChild(script)
})
}
async function bootstrap() {
if (window.qt && !window.QWebChannel) {
try {
await loadQtWebChannelScript()
} catch (_) {
}
}
const { message } = createDiscreteApi(['message'])
window.$message = message
createApp(App).mount('#app')
}
bootstrap()
+299
View File
@@ -0,0 +1,299 @@
:root {
--bg: #f2f4f7;
--panel: #ffffff;
--line: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--brand: #22c1c3;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: linear-gradient(180deg, #f5f7fb 0%, #eef2f7 100%);
color: var(--text);
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
button,
input,
select,
textarea {
font: inherit;
}
.page-shell {
height: 100vh;
overflow: hidden;
}
.content-root {
height: calc(100vh - 48px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.content-scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.topbar {
height: 48px;
background: rgba(255, 255, 255, 0.92);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 20;
user-select: none;
}
.drag-region {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
cursor: default;
}
.inner {
width: min(1320px, calc(100vw - 24px));
margin: 0 auto;
}
.window-actions {
display: flex;
align-items: center;
gap: 4px;
}
.win-btn {
width: 32px;
height: 28px;
border-radius: 8px;
border: 1px solid #e7ecf2;
background: #fff;
color: #4b5563;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.win-btn:hover {
background: #f5f7fb;
}
.win-btn.close:hover {
background: #fee2e2;
color: #991b1b;
border-color: #fecaca;
}
.panel {
background: var(--panel);
border: 1px solid #edf0f4;
border-radius: 14px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.hero {
background: linear-gradient(120deg, rgba(34, 193, 195, 0.15), rgba(124, 58, 237, 0.08));
border: 1px solid #dff4f5;
}
.tab-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e8ecf2;
border-radius: 12px;
padding: 8px 12px 0;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.03);
overflow: hidden;
}
.app-tabs,
.app-tabs .n-tabs-nav-scroll-content,
.app-tabs .n-tabs-pane-wrapper,
.app-tabs .n-tab-pane,
.app-tab-pane {
min-height: 0;
}
.app-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.app-tabs .n-tabs-nav {
flex: 0 0 auto;
}
.app-tabs .n-tabs-pane-wrapper {
flex: 1;
min-height: 0;
}
.app-tabs .n-tab-pane,
.app-tab-pane {
height: 100%;
display: flex;
flex-direction: column;
}
.app-tab-pane > * {
flex: 1;
min-height: 0;
}
.label-title {
font-weight: 600;
font-size: 14px;
color: #1f2937;
}
.label-desc {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
.row-line {
border-bottom: 1px dashed #edf1f5;
padding: 14px 0;
}
.row-line:last-child {
border-bottom: 0;
}
.compact-card .n-card-header {
padding-bottom: 8px !important;
}
.compact-card .n-card__content {
padding-top: 0 !important;
}
.panel-view-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.logs-view-shell {
flex: 1;
min-height: 0;
padding: 12px 0;
}
.log-panel {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.log-panel .n-card__content {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.log-panel .n-card-header {
flex: 0 0 auto;
}
.log-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.log-toolbar-right {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.log-path {
color: #6b7280;
font-size: 12px;
max-width: 520px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-stream-wrap {
height: clamp(320px, calc(100vh - 320px), 680px);
min-height: 320px;
max-height: 680px;
border: 1px solid #e6ebf2;
border-radius: 12px;
background: #0f172a;
overflow-y: auto;
overflow-x: hidden;
}
.log-stream {
margin: 0;
padding: 14px 16px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, "SFMono-Regular", Monaco, monospace;
}
.log-line {
white-space: pre-wrap;
}
.log-line--error {
color: #fca5a5;
}
.log-line--warning {
color: #fde68a;
}
.log-line--info {
color: #bfdbfe;
}
.log-line--debug {
color: #cbd5e1;
}
+12
View File
@@ -0,0 +1,12 @@
export function formatDateTime(value) {
if (!value) return ''
const d = new Date(value)
if (Number.isNaN(d.getTime())) return String(value)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
+228
View File
@@ -0,0 +1,228 @@
<script setup>
import { h, computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { NButton, NCard, NDataTable, NGi, NGrid, NInput, NModal, NSelect, NSpace } from 'naive-ui'
import { clearLogs, getLogEventJson, getLogEvents, getLogSummary } from '../api/bot'
const pollTimer = ref(null)
const loading = ref(false)
const detailVisible = ref(false)
const detailJson = ref('')
const keyword = ref('')
const moduleValue = ref('')
const domainValue = ref('')
const levelValue = ref('')
const onlyAbnormal = ref(false)
const events = ref([])
const summary = ref({ error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] })
const domainOptions = [
{ label: '全部功能域', value: '' },
{ label: '接口', value: 'api' },
{ label: '机器人', value: 'bot' },
{ label: 'OCR', value: 'ocr' },
{ label: 'AI', value: 'ai' },
{ label: '数据库', value: 'db' },
{ label: '截图', value: 'capture' },
]
const moduleOptions = [
{ label: '全部模块', value: '' },
{ label: '接口(api)', value: 'api' },
{ label: '机器人(bot)', value: 'bot' },
{ label: 'OCR(ocr)', value: 'ocr' },
{ label: 'AI(ai)', value: 'ai' },
{ label: '数据库(db)', value: 'db' },
{ label: '截图(capture)', value: 'capture' },
{ label: '审计(audit)', value: 'audit' },
{ label: '错误(error)', value: 'error' },
]
const levelOptions = [
{ label: '全部级别', value: '' },
{ label: 'INFO', value: 'INFO' },
{ label: 'WARNING', value: 'WARNING' },
{ label: 'ERROR', value: 'ERROR' },
]
const domainLabelMap = {
api: '接口',
bot: '机器人',
ocr: 'OCR',
ai: 'AI',
db: '数据库',
capture: '截图',
}
const renderedDomainStats = computed(() => {
const items = Array.isArray(summary.value?.domain_counts) ? summary.value.domain_counts : []
return items
.filter((x) => Number(x?.count || 0) > 0)
.map((x) => `${domainLabelMap[x.domain] || x.domain}: ${x.count}`)
.join(' | ')
})
const filteredEvents = computed(() => {
let rows = events.value
if (domainValue.value) {
rows = rows.filter((e) => String(e.domain || '') === domainValue.value)
}
if (onlyAbnormal.value) {
rows = rows.filter((e) => {
const lv = String(e.level || '').toUpperCase()
const st = String(e.status || '').toLowerCase()
return lv === 'ERROR' || lv === 'WARNING' || st === 'failed' || st === 'fail'
})
}
return rows
})
function formatLogTs(value) {
const raw = String(value || '').trim()
if (!raw) return '-'
const d = new Date(raw)
if (Number.isNaN(d.getTime())) {
return raw.replace('T', ' ').replace(/\.\d+([+-]\d{2}:?\d{2}|Z)?$/, '')
}
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
}
async function onViewJson(row) {
detailVisible.value = true
detailJson.value = '加载中...'
const eventId = String(row?.event_id || '').trim()
if (!eventId) {
detailJson.value = JSON.stringify(row || {}, null, 2)
return
}
try {
const res = await getLogEventJson(eventId)
detailJson.value = JSON.stringify(res?.item || row || {}, null, 2)
} catch {
detailJson.value = JSON.stringify(row || {}, null, 2)
}
}
async function onClearLogs() {
await clearLogs(moduleValue.value || '')
await refreshLogs()
}
const columns = [
{ title: '时间', key: 'ts', width: 170, render: (row) => formatLogTs(row.ts) },
{ title: '功能域', key: 'domain', width: 90 },
{ title: '级别', key: 'level', width: 90 },
{ title: '模块', key: 'module', width: 90 },
{ title: '事件', key: 'event', width: 220 },
{ title: '阶段', key: 'stage', width: 90 },
{ title: '状态', key: 'status', width: 90 },
{ title: 'trace_id', key: 'trace_id', width: 180 },
{ title: '原因', key: 'reason', width: 160 },
{ title: '消息', key: 'message', minWidth: 260 },
{
title: '操作',
key: 'actions',
width: 100,
render: (row) => h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => onViewJson(row) }, { default: () => 'JSON' }),
},
]
async function refreshLogs() {
loading.value = true
try {
const [eventRes, summaryRes] = await Promise.all([
getLogEvents({ module: moduleValue.value, level: levelValue.value, keyword: keyword.value, page: 1, size: 200 }),
getLogSummary(300),
])
events.value = Array.isArray(eventRes?.items) ? eventRes.items : []
summary.value = summaryRes || { error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] }
} catch {
events.value = []
summary.value = { error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] }
} finally {
loading.value = false
}
}
function resetFilters() {
domainValue.value = ''
moduleValue.value = ''
levelValue.value = ''
keyword.value = ''
onlyAbnormal.value = false
refreshLogs()
}
function toggleAbnormal() {
onlyAbnormal.value = !onlyAbnormal.value
}
function startPolling() {
if (pollTimer.value) clearInterval(pollTimer.value)
pollTimer.value = setInterval(() => {
refreshLogs()
}, 2000)
}
onMounted(async () => {
await refreshLogs()
startPolling()
})
onBeforeUnmount(() => {
if (pollTimer.value) {
clearInterval(pollTimer.value)
pollTimer.value = null
}
})
</script>
<template>
<div class="py-3">
<n-card class="panel compact-card mb-3" title="筛选" size="small" :bordered="false">
<n-grid :cols="24" :x-gap="12" :y-gap="12">
<n-gi :span="4"><n-select v-model:value="domainValue" :options="domainOptions" placeholder="功能域" /></n-gi>
<n-gi :span="4"><n-select v-model:value="moduleValue" :options="moduleOptions" placeholder="模块" /></n-gi>
<n-gi :span="4"><n-select v-model:value="levelValue" :options="levelOptions" placeholder="级别" /></n-gi>
<n-gi :span="6"><n-input v-model:value="keyword" placeholder="关键词过滤" @keydown.enter="refreshLogs" /></n-gi>
<n-gi :span="6" class="flex justify-end">
<n-space>
<n-button :type="onlyAbnormal ? 'error' : 'default'" @click="toggleAbnormal">{{ onlyAbnormal ? '仅异常:' : '仅异常:' }}</n-button>
<n-button type="warning" @click="onClearLogs">清除日志</n-button>
<n-button type="primary" :loading="loading" @click="refreshLogs">搜索</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-gi>
</n-grid>
</n-card>
<n-card class="panel compact-card mb-3" title="统计" size="small" :bordered="false">
<div style="color: #8c8c8c; font-size: 12px;">
<span>错误数: {{ summary.error_count || 0 }}</span>
<span style="margin-left: 12px;">回退数: {{ summary.fallback_count || 0 }}</span>
<span style="margin-left: 12px;">{{ renderedDomainStats || '域分布: 无' }}</span>
</div>
</n-card>
<n-card class="panel" size="small" :bordered="false">
<n-data-table
:columns="columns"
:data="filteredEvents"
:loading="loading"
:pagination="{ pageSize: 20 }"
:max-height="560"
size="small"
striped
/>
</n-card>
<n-modal v-model:show="detailVisible" preset="card" title="日志JSON" style="width: 760px">
<pre style="max-height: 520px; overflow: auto; margin: 0; font-size: 12px; white-space: pre-wrap; word-break: break-all;">{{ detailJson }}</pre>
</n-modal>
</div>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup>
import { NButton, NCard, NDataTable } from 'naive-ui'
defineProps({
columns: {
type: Array,
required: true,
},
messages: {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: true,
},
onRefresh: {
type: Function,
required: true,
},
})
</script>
<template>
<div class="py-3">
<n-card class="panel" size="small" :bordered="false">
<template #header-extra><n-button @click="onRefresh">刷新</n-button></template>
<n-data-table :columns="columns" :data="messages" :loading="loading" :pagination="{ pageSize: 10 }" :max-height="520" />
</n-card>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { NButton, NCard, NRadioButton, NRadioGroup, NSelect, NSwitch } from 'naive-ui'
import { getBotLogs } from '../api/bot'
import { fallbackModeOptions } from '../constants/options'
const props = defineProps({
settings: {
type: Object,
required: true,
},
onListenerChange: {
type: Function,
required: true,
},
onSaveSettings: {
type: Function,
required: true,
},
})
const logKind = ref('bot')
const logLines = ref([])
const logPath = ref('')
const logLoading = ref(false)
const logError = ref('')
const logTimer = ref(null)
const renderedLogLines = computed(() => {
if (logLoading.value && !logLines.value.length) {
return [{ text: '日志加载中...', level: 'info' }]
}
if (logError.value) {
return [{ text: `日志加载失败:${logError.value}`, level: 'error' }]
}
if (!logLines.value.length) {
return [{ text: '暂无日志输出', level: 'debug' }]
}
return logLines.value.map((line) => ({
text: line,
level: getLogLevel(line),
}))
})
function getLogLevel(line) {
const text = String(line || '').toUpperCase()
if (text.includes('ERROR')) return 'error'
if (text.includes('WARNING') || text.includes('WARN')) return 'warning'
if (text.includes('INFO')) return 'info'
return 'debug'
}
async function loadLogs() {
logLoading.value = true
try {
const data = await getBotLogs(logKind.value, 120)
if (!data?.success) throw new Error(data?.message || '接口返回失败')
logLines.value = Array.isArray(data.lines) ? data.lines : []
logPath.value = data.path || ''
logError.value = ''
} catch (error) {
logError.value = error?.message || String(error)
} finally {
logLoading.value = false
}
}
function setLogKind(value) {
logKind.value = value
}
function startLogPolling() {
if (logTimer.value) clearInterval(logTimer.value)
logTimer.value = setInterval(loadLogs, 2000)
}
onMounted(async () => {
await loadLogs()
startLogPolling()
})
watch(logKind, () => {
loadLogs()
})
onBeforeUnmount(() => {
if (logTimer.value) {
clearInterval(logTimer.value)
logTimer.value = null
}
})
</script>
<template>
<div class="py-3 panel-view-shell">
<n-card class="panel compact-card" title="监听与回复设置" size="small" :bordered="false">
<div class="row-line flex items-center justify-between">
<div><div class="label-title">监听</div><div class="label-desc">执行监听开始监听微信</div></div>
<n-switch :value="props.settings.listener_enabled" @update:value="props.onListenerChange" />
</div>
<div class="row-line flex items-center justify-between">
<div><div class="label-title">自动回复</div><div class="label-desc">开启后自动回复微信收到的信息</div></div>
<n-switch :value="props.settings.auto_reply_enabled" @update:value="value => props.onSaveSettings({ auto_reply_enabled: value ? '1' : '0' })" />
</div>
<div class="row-line flex items-center justify-between">
<div><div class="label-title">全量回复</div><div class="label-desc">开启后回复所有消息</div></div>
<n-switch :value="props.settings.full_auto_reply_enabled" @update:value="value => props.onSaveSettings({ full_auto_reply_enabled: value ? '1' : '0' })" />
</div>
<div class="pt-3 max-w-[240px]">
<n-select :options="fallbackModeOptions" :value="props.settings.reply_fallback_mode" @update:value="value => props.onSaveSettings({ reply_fallback_mode: value || 'ai' })" />
</div>
</n-card>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup>
import { onMounted } from 'vue'
import { NButton, NCard, NDataTable, NGi, NGrid, NInput, NModal, NSelect, NSpace } from 'naive-ui'
import { matchTypeFilterOptions, matchTypeOptions, ruleStatusOptions } from '../constants/options'
import { useRulesManager } from '../composables/useRulesManager'
const props = defineProps({
message: {
type: Object,
required: true,
},
})
const {
loadingRules,
creatingRule,
showCreateRuleModal,
filters,
createForm,
filteredRules,
ruleColumns,
loadRules,
submitCreateRule,
resetFilters,
} = useRulesManager(props.message)
onMounted(() => {
loadRules()
})
defineExpose({ loadRules })
</script>
<template>
<div class="py-3">
<n-card class="panel compact-card mb-3" title="筛选" size="small" :bordered="false">
<n-grid :cols="24" :x-gap="12" :y-gap="12">
<n-gi :span="6"><n-input v-model:value="filters.keyword" placeholder="关键词" /></n-gi>
<n-gi :span="5"><n-select v-model:value="filters.match_type" :options="matchTypeFilterOptions" /></n-gi>
<n-gi :span="5"><n-select v-model:value="filters.status" :options="ruleStatusOptions" /></n-gi>
<n-gi :span="8" class="flex justify-end"><n-space><n-button type="primary" @click="loadRules">搜索</n-button><n-button @click="resetFilters">重置</n-button></n-space></n-gi>
</n-grid>
</n-card>
<n-card class="panel compact-card mb-3" title="新增规则" size="small" :bordered="false">
<div class="flex justify-end">
<n-button type="primary" @click="showCreateRuleModal = true">新增回复规则</n-button>
</div>
</n-card>
<n-modal
:show="showCreateRuleModal"
preset="card"
title="新增回复规则"
class="w-[640px]"
:mask-closable="false"
@update:show="value => showCreateRuleModal = value"
>
<n-grid :cols="24" :x-gap="12" :y-gap="12">
<n-gi :span="24"><n-input v-model:value="createForm.keyword" placeholder="关键词" /></n-gi>
<n-gi :span="24"><n-select v-model:value="createForm.match_type" :options="matchTypeOptions" /></n-gi>
<n-gi :span="24"><n-input v-model:value="createForm.reply_text" type="textarea" :autosize="{ minRows: 3, maxRows: 6 }" placeholder="回复内容" /></n-gi>
</n-grid>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="showCreateRuleModal = false">取消</n-button>
<n-button type="primary" :loading="creatingRule" @click="submitCreateRule">确认新增</n-button>
</div>
</template>
</n-modal>
<n-card class="panel" size="small" :bordered="false">
<n-data-table :columns="ruleColumns" :data="filteredRules" :loading="loadingRules" :pagination="{ pageSize: 10 }" :max-height="360" />
</n-card>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: 'localhost',
port: 1420,
proxy: {
'/api': 'http://127.0.0.1:5000',
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})