Compare commits

...

9 Commits

Author SHA1 Message Date
e2hang
e52101da7c Update 2026-03-01 10:33:26 +08:00
e2hang
35e80f1e14 Update: task, state, part of log 2026-02-27 21:48:14 +08:00
e2hang
fe28002782 Update: blog.mdx - 3 2026-02-27 11:21:59 +08:00
e2hang
2733b4edf1 Update: blog.mdx - 2 2026-02-27 10:13:49 +08:00
e2hang
e70b3933ae Update: blog.mdx - 1 2026-02-27 10:03:45 +08:00
e2hang
00af76a8ce Update .gitignore 2026-02-26 23:13:46 +08:00
e2hang
877677219e Update Task, Notifier; Update docs/plan 2026-02-26 23:11:21 +08:00
e2hang
88d0d17c51 New Readme.md 2026-02-24 23:26:15 +08:00
E2hang
636e5e1fec Struct Task, TaskStatus, TaskRuntime 2026-02-24 23:09:18 +08:00
9 changed files with 759 additions and 9 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target /target
Cargo.lock

7
Cargo.lock generated Normal file
View File

@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "health-guard"
version = "0.1.0"

View File

@@ -1,3 +1,9 @@
# hguard # hguard
A Software Guarding Your Health A Software Guarding Your Health
## Progress
- Now working on **hguard-core**
- Preparing to work on **hguard-cli**
After developing main contents, Tray Icon and GUI Windows will be considered

84
docs/blog.mdx Normal file
View File

@@ -0,0 +1,84 @@
---
title: 【开源项目】HGuard: 一个桌面健康守护者
date: 2026-02-26
tags: [开源项目, 健康, AI]
---
# 一、项目背景
身体是革命的本钱。对于久坐办公的人群而言,身体健康更需要引起重视。写代码、写论文、查资料、看文档……
有时候一抬头,已经过去三四个小时。这样的生活方式是极其不健康的。为了缓解工作学习对身体带来的负担,
我开始做一个属于自己的小工具:它叫 **HealthGuard**,简称**HGuard**。
---
# 二、项目现状
目前的V1.0版本是一个后台运行的托盘程序通过hguard-tray运行所有的提醒都会记录在一个log文件中。
可以在`config.toml`配置文件中写入需要提醒的任务,程序启动的时候会读取配置文件并按时提醒:
弹出提示并播放预先设定好的音频。
详细信息可以到如下网址进行查看
https://huajishe.fun/git/e2hang/HuajisheTools/releases/tag/HealthDaemon
同步开发仓库如下
https://huajishe.fun/git/e2hang/hguard
---
# 三、项目前景
## 想法
单纯的提醒工具是没有灵魂的,如果把它变成一个桌面上的电子桌宠,又能打趣地提醒我该起立喝水了,
还可以实时查看我的屏幕,并且根据我的屏幕变化随时作出反应,这将是极好的。
这样就可以把单纯的提醒工具变成一个写代码、读文献时的伙伴,既可以帮你理解内容,查看你的代码问题,
还可以进行健康提醒,极大地提高工作效率。
## 实现步骤
### 第一阶段(现在)
- 后台常驻
- 可配置提醒
- 稳定运行
### 第二阶段
- 桌面悬浮小窗口
- 一个简单可见的形象类似Live2d
### 第三阶段
- 接入 AI本地LLM或云端API 文本生成
- 让提醒变得更自然
### 第四阶段
- 可选的屏幕内容理解
- 更情境化的互动
每一步都可以独立完成、独立运行,慢慢进化。
---
## 四、实现难点与注意事项
### 本地LLM或云端API
本地LLM消耗的资源会很多比较好的选择还是以云端API通过服务器返回结果
### 屏幕理解部分
采用OCR或多模态模型处理处理后返回本地系统
### 隐私与边界
屏幕理解功能:
- 功能默认关闭
- 明确提示是否上传数据
- 用户可完全控制
---
如想讨论相关内容可以致信huajishe@gmail.com或在git中留言

347
docs/plan.mdx Normal file
View File

@@ -0,0 +1,347 @@
````mdx
---
title: 从托盘提醒到 AI 桌宠HGuard 的设计与进化路线
date: 2026-02-26
tags: [Rust, 桌面应用, 系统设计, AI, 桌宠]
---
# 从托盘提醒到 AI 桌宠HGuard 的设计与进化路线
HGuard 最初只是一个非常简单的想法:
> 我需要一个能提醒我起立和喝水的工具。
作为一个长期写代码、写算法、折腾系统的人,我发现“久坐”不是偶然,而是一种常态。现有工具要么太重,要么过于商业化,要么缺少可扩展性。
于是我决定自己写一个。
但在实现基础功能之后,我意识到:
**它不应该只是一个提醒工具。**
我希望它最终成为:
- 一个后台健康守护系统
- 一个可交互的桌面存在体
- 一个具备 AI 对话能力的桌宠
- 一个能够理解当前屏幕内容的上下文助手
这篇文章记录 HGuard 当前的实现状态,以及未来明确、可执行的演进路线。
---
# 一、当前版本:一个稳定的后台健康守护核心
当前版本的 HGuard 已实现以下能力:
## 1. 托盘常驻
- 程序以托盘应用形式运行
- 不打扰桌面工作
- 支持随时退出或暂停
## 2. 可配置任务系统
使用 `config.toml` 管理任务,例如:
```toml
[[tasks]]
name = "stand_up"
interval_minutes = 60
message = "该起立活动一下了"
[[tasks]]
name = "drink_water"
interval_minutes = 20
message = "喝水时间到"
````
优点:
* 用户无需重新编译程序
* 扩展任务非常简单
* 未来可支持更多任务类型
## 3. 异步调度引擎
核心采用 async 架构,调度结构大致如下:
```
engine
├── scheduler
├── task
├── notifier
```
### engine
* 启动系统
* 加载配置
* 初始化任务
### scheduler
* 管理任务周期
* 基于时间轮或 interval 驱动
### task
* 定义任务状态
* 管理运行记录
### notifier
* 触发提醒
* 未来可扩展为多种通知形式
这种结构保证:
* 调度逻辑与通知逻辑解耦
* 可随时添加新类型任务
* 未来可平滑接入 AI 模块
当前版本定位非常明确:
> 一个稳定、轻量、后台运行的健康守护内核。
---
# 二、架构升级目标:模块分层与 AI 解耦
未来 HGuard 不会把 AI 写进核心模块,而是采用分层架构。
计划结构:
```
core/
engine.rs
scheduler.rs
task.rs
notifier.rs
ai/
llm_client.rs
context_memory.rs
screen_analyzer.rs
ui/
desktop_window.rs
interaction.rs
live2d_renderer.rs
system/
screen_capture.rs
permission.rs
```
原则:
* Core 不依赖 AI
* AI 作为增强能力存在
* UI 与 Core 通过 IPC 通信
这样可以保证:
* 即使 AI 模块崩溃,健康提醒仍然运行
* 可以替换不同 LLM API
* 可关闭屏幕分析功能
---
# 三、阶段性演进路线(可实施版本)
## Phase 1已完成
* 托盘运行
* 可配置任务
* 异步调度
* 基础通知系统
目标:稳定运行。
---
## Phase 2桌面悬浮窗口
目标:让 HGuard “可见”。
### 实现步骤:
1. 使用 Tauri 或 winit 创建无边框窗口
2. 设置透明背景
3. 支持置顶
4. 支持拖动
5. 显示当前任务状态
这个阶段不接入 AI只做 UI 可视化。
---
## Phase 3接入 AI 文本生成
目标:让提醒“更有情绪”。
### 实现步骤:
1. 新建 ai/llm_client.rs
2. 支持 API Key 配置
3. 设计基础 Prompt
```
你是一个桌面健康助理。
用户喜欢写代码。
提醒语气略带调侃但不过分。
```
4. 每次提醒时:
* 传入任务类型
* 生成变体提醒语
例如:
> 你已经连续写了 60 分钟代码了,是时候站起来救救你的脊椎了。
---
## Phase 4屏幕理解能力
目标:实现“上下文感知”。
### 技术路线:
1. 每 N 秒截屏(默认关闭)
2. OCR 提取文本
3. 或调用多模态模型
4. 生成 contextual 对话
示例:
* 当前在 VSCode
* 正在编辑 Rust 文件
* 超过 2 小时未切换窗口
生成:
> 你是真的喜欢 Rust还是只是逃避现实
⚠ 必须提供隐私开关。
---
## Phase 5Live2D 桌宠
目标:可视化人格。
### 技术选型:
推荐:
* 前端 + Live2D SDK
* Rust 后端通过 IPC 通信
实现:
* 不同情绪切换动画
* 提醒时触发动作
* 被点击时触发对话
---
## Phase 6情绪系统与记忆系统
定义简单状态机:
* Idle
* Reminder
* Playful
* Concerned
记录:
* 今日提醒次数
* 忽略次数
* 活跃时长
形成持续人格。
---
# 四、性能与隐私设计原则
在设计 AI 桌宠时必须考虑:
## 1. 不高频截图
默认关闭屏幕理解。
## 2. 本地优先
优先本地 OCR 或模型。
## 3. 明确告知用户
说明:
* 截图是否上传
* 是否保存数据
* 是否长期存储
健康工具不应成为监控工具。
---
# 五、最终愿景
HGuard 的终极形态不是一个简单工具,而是:
* 一个长期运行的后台守护系统
* 一个具备情境感知能力的桌面助手
* 一个拥有轻量人格的陪伴型 AI
它不会干扰工作。
但它会存在。
---
# 六、当前进度总结
已完成:
* 核心调度系统
* 配置驱动任务
* 托盘常驻
* 异步架构
正在进行:
* UI 可视化方案设计
* AI 接口抽象
下一步:
* 实现桌面悬浮窗口
* 接入基础 LLM API
---
# 七、为什么要做这个项目?
因为我发现:
健康不是“知道该做什么”,
而是需要一个系统长期提醒你。
而陪伴也不是“热闹”,
而是一个持续存在的反馈体。
HGuard 试图把这两件事结合起来。
这是一个长期项目。
它不会一蹴而就。
但它会持续进化。
```

23
src/core/log.rs Normal file
View File

@@ -0,0 +1,23 @@
pub struct log {
title:String,
message:String,
}
impl log {
pub fn new(title:&str, message:&str) -> Self {
Self{
title:title.to_string(),
message:message.to_string(),
}
}
}
pub trait error {
pub fn ret(&self);
pub fn del(&self);
}
impl error for log {
}

141
src/core/notifier.rs Normal file
View File

@@ -0,0 +1,141 @@
use anyhow::Result;
use chrono::Local;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{error, warn};
use crate::task::Task;
// Syscall
pub fn send_system_notification(task: &Task) -> Result<()> {
notify_rust::Notification::new()
.summary(&task.title)
.body(&task.body)
.appname("健康守护助手")
.sound_name("default")
.timeout(notify_rust::Timeout::Milliseconds(8000))
.show()?;
Ok(())
}
/// Play Sound .path
pub fn play_sound_file(path: &str) {
use rodio::{Decoder, OutputStream, Sink};
use std::fs::File;
use std::io::BufReader;
let path = path.to_string();
std::thread::spawn(move || {
let result = (|| -> Result<()> {
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
let file = File::open(&path)?;
let source = Decoder::new(BufReader::new(file))?;
sink.append(source);
sink.sleep_until_end();
Ok(())
})();
if let Err(e) = result {
warn!("音频文件播放失败({}),降级为内置蜂鸣", e);
play_beep();
}
});
}
/// Beep Sound
pub fn play_beep() {
use rodio::{OutputStream, Sink, Source};
use rodio::source::SineWave;
std::thread::spawn(|| {
if let Ok((_stream, handle)) = OutputStream::try_default() {
if let Ok(sink) = Sink::try_new(&handle) {
for _ in 0..3 {
let tone = SineWave::new(660.0)
.take_duration(Duration::from_millis(500))
.amplify(0.6);
let gap = SineWave::new(1.0)
.take_duration(Duration::from_millis(150))
.amplify(0.0);
sink.append(tone);
sink.append(gap);
}
sink.sleep_until_end();
}
}
});
}
/// Play Sound
pub fn play_sound(sound_file: &str) {
if sound_file.is_empty() {
play_beep();
} else {
play_sound_file(sound_file);
}
}
//File Checker
#[derive(Clone)]
pub struct FileLogger {
log_path: PathBuf,
lock: Arc<Mutex<()>>,
}
impl FileLogger {
/// Init Logs
pub fn new(log_dir: &str) -> Self {
let dir = if log_dir.is_empty() {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("health-guardian")
} else {
PathBuf::from(log_dir)
};
let _ = std::fs::create_dir_all(&dir);
let log_path = dir.join("health-guardian.log");
let logger = Self { log_path, lock: Arc::new(Mutex::new(())) };
logger.write_session_header();
logger
}
fn write_session_header(&self) {
let now = Local::now();
let line = format!(
"\n{sep}\n 会话开始 {ts}\n{sep}\n",
sep = "".repeat(72),
ts = now.format("%Y-%m-%d %H:%M:%S"),
);
self.append(&line);
}
/// Record Trigger (Once)
pub fn log_fire(&self, task: &Task, count: u32) {
let now = Local::now();
let line = format!(
"[{ts}] #{count:>4} {name:<12} {title}\n",
ts = now.format("%Y-%m-%d %H:%M:%S"),
count = count,
name = task.name,
title = task.title,
);
self.append(&line);
}
fn append(&self, content: &str) {
let _g = self.lock.lock().unwrap();
match std::fs::OpenOptions::new()
.create(true).append(true).open(&self.log_path)
{
Ok(mut f) => { let _ = f.write_all(content.as_bytes()); }
Err(e) => error!("日志写入失败: {}", e),
}
}
pub fn path(&self) -> &PathBuf { &self.log_path }
}

67
src/core/state.rs Normal file
View File

@@ -0,0 +1,67 @@
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use crate::task::*;
#[derive(Debug)]
struct StateInner {
stats: HashMap<String, TaskRuntime>,
running: bool,
}
#[derive(Debug, Clone)]
pub struct AppState {
inner: Arc<RwLock<StateInner>>,
}
impl AppState {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(
StateInner {
stats: HashMap::new(),
running: true,
}
)),
}
}
// Initial Counting Statics
pub fn register_task(&self, stats: TaskRuntime) {
let mut w = self.inner.write().unwrap();
w.stats.insert(stats.tasks.name.clone(), stats);
}
// Update Record
pub fn record(&self, name:&str, next: chrono::DateTime<chrono::Local>) {
let mut w = self.inner.write().unwrap();
if let Some(s) = w.stats.get_mut(name) {
s.record(next);
}
}
// Read Snapshots(All tasks)
pub fn snapshot(&self) -> Vec<TaskRuntime> {
let r = self.inner.read().unwrap();
r.stats.values().cloned().collect();
}
// Read Snapshots(Single tasks)
pub fn get_stats(&self, name:&str) -> Option<TaskRuntime> {
let r = self.inner.read().unwrap();
r.stats.get(name).cloned()
}
// Is Running?
pub fn is_running(&self) -> bool {
self.inner.read().unwrap().running
}
// Request Stop
pub fn request_stop(&self) {
self.inner.write().unwrap().running = false;
}
}
impl Default for AppState {
fn default() -> Self { Self::new() }
}

View File

@@ -1,22 +1,95 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
// Time Length
pub enum Interval {
Hours(u64),
Minutes(u64),
Seconds(u64),
}
// Unified to minutes, no *secs* or *hours*
impl Interval {
pub fn to_min(self) -> u64 {
match self {
Interval::Hours => self / 60,
Interval::Minutes => self,
Interval::Seconds => self * 60,
}
}
pub fn display(self) {
match self {
Interval::Hours => format!("Every {} minutes", self.to_min()),
Interval::Minutes => format!("Every {} minutes", self),
Interval::Seconds => format!("Every {} minutes", self.to_min()),
}
}
}
// Task - Pure Data
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Task { pub struct Task {
name:String, pub name:String,
interval:Duration, //Use min, u64 should be divided by 60 pub interval:Duration, //Use min, u64 should be divided by 60
message:String, pub title:String,
last_trigger:Instant, pub body:String,
pub last_trigger:Instant,
pub sound:bool,
pub enable:bool,
} }
impl Task { impl Task {
pub fn new(name:&str, interval_mins:u64, message:&str) -> Self { pub fn new(name:&str,
interval_mins:u64,
title:&str,
body:&str,
sound:bool,
enable:bool ) -> Self {
Self { Self {
name: name.to_string(), name: name.to_string(),
interval: Duration::from_secs(interval_mins * 60), interval: Duration::from_secs(interval_mins * 60),
message: message.to_string(), title: title.to_string(),
body: body.to_string(),
last_trigger: Instant::now(), last_trigger: Instant::now(),
sound: sound,
enable: enable,
}
} }
} }
pub fn trigger() {
// Enum of Status
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum TaskStatus {
Idle,
Running,
Paused,
Error(String), // with error messages
}
// Main: TaskRuntime
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TaskRuntime {
pub task: Task,
pub task_status: TaskStatus,
pub count: u32,
pub last_time: Option<DateTime<Local>>,
pub new_time: DateTime<Local>,
}
impl TaskRuntime {
pub fn new(task:&Task, task_status:&TaskStatus) -> Self {
let nxt = Local::now() + chrono::Duration::minutes(task.initial_delay_minutes as i64);
Self {
task:task,
task_status:task_status,
count:0,
last_time: None,
new_time: nxt,
}
}
pub fn record(&mut self, next: DataTime<Local>) {
self.count += 1;
self.last_time = Some(Local::now());
self.next_time = next;
} }
} }