Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b0c313e
Remove dead components, exports and text entries; test that every tex…
fylorn Oct 8, 2026
2005956
Bump dev-only dependencies flagged by Dependabot
fylorn Oct 8, 2026
32f7c61
Dashboard, key usage and upstream stats ask core in parallel with one…
fylorn Oct 8, 2026
f009652
Clients page asks the gateway for models once per key, in parallel
fylorn Oct 8, 2026
d84b6bc
Config file and version history load through useResource with error a…
fylorn Oct 8, 2026
6505303
Start reading the login-shell environment before core needs it
fylorn Oct 8, 2026
1c94067
Event bridge and heartbeat wait for core instead of polling
fylorn Oct 8, 2026
839c920
Settings and notice commands write their files off the main thread
fylorn Oct 8, 2026
14ae94b
Move the shared dialog and row kit into src/ui; AlertDialogAction get…
fylorn Oct 8, 2026
01d1658
A tray icon or window that fails at startup no longer quits the app
fylorn Oct 8, 2026
b267345
Compile the scan rules once and read client files off async workers
fylorn Oct 8, 2026
ead84b5
Share the sign-in wait between the ChatGPT and Z.ai dialogs
fylorn Oct 8, 2026
0859277
Speed test and price sheet read model lists from the shared cache
fylorn Oct 8, 2026
b412dea
Split the request drawer into src/traffic/drawer
fylorn Oct 8, 2026
5b1a583
Consolidate duplicated helpers in tw-adopt and the app crate
fylorn Oct 8, 2026
b2e1bc8
Write every latency, date and token count through src/format.ts
fylorn Oct 8, 2026
90690f8
Move big test modules out and split the macOS menu bar module
fylorn Oct 8, 2026
9b25248
Tests fail loudly if they reach the real data directory
fylorn Oct 8, 2026
7ddaff0
Extract the plugin editor's code/form sync into a tested engine
fylorn Oct 8, 2026
9f85271
Fix small UI rule breaks: hard-coded label, English spinner label, x …
fylorn Oct 8, 2026
9c0859a
Draw the overview chart as hand-written SVG and drop recharts
fylorn Oct 8, 2026
1d43ad9
One sparkline for keys, upstreams and the traffic header
fylorn Oct 8, 2026
89d0cfe
One meter bar for the overview, sessions, retention, quota and updates
fylorn Oct 8, 2026
e45f682
Document Meter, Sparkline and StackedArea in the UI kit rules
fylorn Oct 8, 2026
000cf40
Parse the core state string in one place
fylorn Oct 8, 2026
5531898
Pause the live chart while the window is hidden
fylorn Oct 8, 2026
662b61b
Load the calendar and the connection dialogs on demand
fylorn Oct 8, 2026
cc82d00
Split the shell, keep live rows out of it, load pages on demand
fylorn Oct 8, 2026
c4890ae
Merge branch 'worktree-agent-afd2bf67ca116087f' into perf/lite-optimi…
fylorn Oct 8, 2026
b222380
Merge branch 'worktree-agent-a0c3cb05b1e96c175' into perf/lite-optimi…
fylorn Oct 8, 2026
12202a8
Merge branch 'worktree-agent-a3401591ba3dca8b4' into perf/lite-optimi…
fylorn Oct 8, 2026
da0454e
Merge branch 'worktree-agent-a9ec697c13fefbae1' into perf/lite-optimi…
fylorn Oct 8, 2026
0d3c32f
Animate the new-row flash and the live dot with opacity and transform…
fylorn Oct 8, 2026
bbaa0cc
Traffic table: one shared row menu, lazy tooltips, windowed rendering
fylorn Oct 8, 2026
20b58fa
Merge branch 'worktree-agent-a8c967d86f082c66d' into perf/lite-optimi…
fylorn Oct 8, 2026
7e99d42
Integrate: shared format and copy helpers in the traffic table, comme…
fylorn Oct 8, 2026
3d6884c
Menu bar menu: Open first, a Today dashboard with hourly tokens, one …
fylorn Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.45.0",
"radix-ui": "^1.6.7",
"react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0",
"recharts": "3.8.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tw-animate-css": "^1.4.0"
Expand All @@ -44,7 +42,7 @@
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.1.0",
"shadcn": "^4.21.0",
"sharp": "0.35.4",
"sharp": "0.35.5",
"tailwindcss": "^4.1.0",
"typescript": "^5.9.0",
"vite": "^7.1.0",
Expand Down
567 changes: 133 additions & 434 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

27 changes: 20 additions & 7 deletions src-tauri/crates/tw-adopt/src/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,28 @@ impl Gateway {
}
}

fn v1(&self) -> String {
/// 带 `/v1` 的地址:OpenAI 形状的客户端拼的是 `{base}/chat/completions` 这种
pub(crate) fn v1(&self) -> String {
format!("{}/v1", self.base.trim_end_matches('/'))
}
}

/// 网关列出来的模型,同名的只算第一个,先后照网关答的。写进配置的清单都过一遍:JSON 对象
/// 里重复的键、TOML 里两张同名的表,各家解析器认哪一个说法不一
pub(crate) fn unique(models: &[ModelCard]) -> Vec<&ModelCard> {
let mut seen = std::collections::HashSet::new();
models
.iter()
.filter(|m| seen.insert(m.id.as_str()))
.collect()
}

/// 网关不要密钥时,在要写密钥的地方写的那个值(Qwen Code 的 `settings.env`、Grok Build 的
/// `api_key`)。**不能留空**:留空的话这两个客户端都会退回用户自己的凭据(各自为什么见
/// [`crate::qwen::edits`]、[`crate::grok`] 文件开头)。这个值什么都打不开,网关会以「没有
/// 这把密钥」拒绝;打码时认得它,它不是谁的密钥
pub(crate) const NO_KEY: &str = "no-key";

/// 我们要往配置里写的一个字段。
///
/// **这张表就是那个白名单,而它枚举的是「我们要写什么」** —— 有限、封闭、
Expand Down Expand Up @@ -1283,12 +1300,8 @@ impl Client {
/// (上游、路由、手写的规格改过)。顺序不算,同名的只算第一个
pub fn models_stale(&self, written: &[ModelCard], now: &[ModelCard]) -> bool {
let norm = |xs: &[ModelCard]| {
let mut seen = std::collections::HashSet::new();
let mut v: Vec<ModelCard> = xs
.iter()
.filter(|m| seen.insert(m.id.as_str()))
.map(|m| self.as_written(m))
.collect();
let mut v: Vec<ModelCard> =
unique(xs).into_iter().map(|m| self.as_written(m)).collect();
v.sort();
v
};
Expand Down
16 changes: 2 additions & 14 deletions src-tauri/crates/tw-adopt/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,6 @@ pub struct Finding {
pub fix: Option<Msg>,
}

/// 只有 `ps` 那一支要:它拿到的是「跑了多久」,得从现在往回倒。Windows
/// 那一支直接拿到创建时刻。
#[cfg(not(windows))]
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}

/// `ps` 的 `etime`:`[[dd-]hh:]mm:ss`。
///
/// macOS 的 `ps` 没有 `etimes`(整秒),只有这个格式;`lstart` 是本地化
Expand Down Expand Up @@ -253,7 +243,7 @@ fn running_since(markers: &[&str]) -> Vec<u64> {
else {
return Vec::new();
};
let now = now_ms();
let now = crate::now_ms();
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(parse_ps_line)
Expand Down Expand Up @@ -474,9 +464,7 @@ fn env_conflicts(_home: &Path, names: &[&str]) -> Vec<EnvConflict> {
HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, RRF_RT_ANY, RegGetValueW,
};

fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
use crate::wide;
fn is_set(root: HKEY, sub: &str, name: &str) -> bool {
let (sub, name) = (wide(sub), wide(name));
let mut len: u32 = 0;
Expand Down
25 changes: 9 additions & 16 deletions src-tauri/crates/tw-adopt/src/foreign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,6 @@ pub struct Applied {
pub warnings: Vec<Msg>,
}

fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}

/// 跟着符号链接走到真身。
///
/// 不用 `canonicalize`:文件还不存在时它直接失败,而「第一次接管、
Expand Down Expand Up @@ -368,17 +361,10 @@ fn replace(tmp: &Path, real: &Path) -> std::io::Result<()> {
/// 也就是 unix 那条 `rename` 在这里的对应物。
#[cfg(windows)]
fn replace(tmp: &Path, real: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use crate::wide;
use windows_sys::Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
};

fn wide(p: &Path) -> Vec<u16> {
p.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
let (from, to) = (wide(tmp), wide(real));
let mv = |flags| {
// SAFETY: 两个参数都是以 NUL 结尾的 UTF-16,函数只读它们。
Expand Down Expand Up @@ -429,6 +415,13 @@ fn replace_in_wsl(
/// 工具会把 mtime 全改成同一天)。序号补零到固定宽度,同一毫秒里的
/// 几份也按先后排。
pub fn backup_root() -> PathBuf {
// 测试里不许落到真的备份目录:测试的备份目录建在临时目录里、显式传进去(桌面端的 `data_dir`
// 有同样的检查)
#[cfg(test)]
assert!(
std::env::var_os("THINKWATCH_HOME").is_some(),
"a test reached the real backup directory; give it a temporary one"
);
tw_api::data::dir().join("backups")
}

Expand Down Expand Up @@ -514,7 +507,7 @@ pub fn is_ours(root: &Path, backup: &Path) -> bool {
}

fn backup_to(root: &Path, real: &Path, text: impl AsRef<[u8]>) -> Result<PathBuf, ForeignError> {
backup_at(root, real, text, now_ms())
backup_at(root, real, text, crate::now_ms())
}

/// 时间戳从外面传进来,测试才能稳定地造出「同一毫秒」。
Expand Down
53 changes: 10 additions & 43 deletions src-tauri/crates/tw-adopt/src/grok.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
//! 它自己也写这份文件(`/model`、`/settings`、自动更新之后),用的是重新序列化,注释和排版
//! 一律丢掉 —— 哨兵注释会跟着没了,旁文件里的记录还在,还原照样做得了。

use crate::clients::{Edit, Gateway, ModelCard};
use crate::clients::{Edit, Gateway, ModelCard, NO_KEY, unique};
use crate::json::Val;
use crate::plan::lookup;
use crate::pi::{count_of, flag_of};
use crate::plan::{lookup, lookup_str};

/// 我们写的模型表的名字:`[model."thinkwatch/<模型>"]`。
///
Expand Down Expand Up @@ -52,15 +53,6 @@ pub fn backend_for(model: &str) -> &'static str {
}
}

/// 网关列出来的模型,同名的只算一次
fn unique(gw: &Gateway) -> Vec<&ModelCard> {
let mut seen = std::collections::HashSet::new();
gw.models
.iter()
.filter(|m| seen.insert(m.id.as_str()))
.collect()
}

/// 一张模型表里的字段。**没有网关密钥也要写一个不为空的 `api_key`**:空着的话 Grok 退回
/// 用户的会话令牌,见文件开头;这个值什么都打不开,网关会以「没有这把密钥」拒绝。
///
Expand All @@ -70,10 +62,7 @@ fn table(gw: &Gateway, m: &ModelCard) -> Vec<(String, Val)> {
let mut t = vec![
("model".into(), Val::s(model)),
("name".into(), Val::s(format!("{model} (ThinkWatch)"))),
(
"base_url".into(),
Val::s(format!("{}/v1", gw.base.trim_end_matches('/'))),
),
("base_url".into(), Val::s(gw.v1())),
("api_backend".into(), Val::s(backend_for(model))),
(
"api_key".into(),
Expand Down Expand Up @@ -109,7 +98,7 @@ fn spec(m: &ModelCard) -> Vec<(String, Val)> {

/// 手动配置那一页列的字段:每张表拆成一项一项,照着就能写。接管写整张表,见 [`edits`]
pub fn fields(gw: &Gateway) -> Vec<Edit> {
let mut v: Vec<Edit> = unique(gw)
let mut v: Vec<Edit> = unique(&gw.models)
.into_iter()
.flat_map(|m| {
let k = key_of(&m.id);
Expand Down Expand Up @@ -139,7 +128,7 @@ pub fn fields(gw: &Gateway) -> Vec<Edit> {
///
/// `current` 是此刻的 config.toml,读不出来就当空的
pub fn edits(gw: &Gateway, current: &str) -> Vec<Edit> {
let models = unique(gw);
let models = unique(&gw.models);
let Some(first) = models.first() else {
return Vec::new();
};
Expand Down Expand Up @@ -177,9 +166,6 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec<Edit> {
v
}

/// 网关不要密钥时写进 `api_key` 的那个值。见 [`edits`]
pub const NO_KEY: &str = "no-key";

/// 此刻 `[models] default` 选着的那一张
fn default_in(text: &str) -> Option<String> {
match crate::toml::get(text, &["models", "default"]).ok()?? {
Expand Down Expand Up @@ -211,19 +197,6 @@ fn ours(text: &str) -> Vec<(String, Val)> {
}
}

fn field(v: &Val, key: &str) -> Option<String> {
match v {
Val::Obj(ms) => ms
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| match v {
Val::Str(s) => Some(s.clone()),
_ => None,
}),
_ => None,
}
}

/// 配置里此刻指向哪儿:`[models] default` 选着的那一张是我们的,就是它的地址。
///
/// 默认模型换回了别的(xAI 的内置模型、用户自己的表),Grok 平常就不走网关了,那就是「我们
Expand Down Expand Up @@ -259,15 +232,9 @@ pub fn models_in(text: &str) -> Option<Vec<ModelCard>> {
ours(text)
.iter()
.map(|(k, t)| ModelCard {
id: field(t, "model").unwrap_or_else(|| k[KEY_PREFIX.len()..].to_string()),
context_window: match lookup(t, &["context_window"]) {
Some(Val::Num(n)) => n.parse().ok(),
_ => None,
},
reasoning: match lookup(t, &["supports_reasoning_effort"]) {
Some(Val::Bool(b)) => Some(b),
_ => None,
},
id: lookup_str(t, &["model"]).unwrap_or_else(|| k[KEY_PREFIX.len()..].to_string()),
context_window: count_of(lookup(t, &["context_window"])),
reasoning: flag_of(lookup(t, &["supports_reasoning_effort"])),
..Default::default()
})
.collect(),
Expand Down Expand Up @@ -306,7 +273,7 @@ pub fn secrets(text: &str) -> Vec<String> {
match crate::toml::get(text, &["model"]).ok().flatten() {
Some(Val::Obj(ms)) => ms
.iter()
.filter_map(|(_, t)| field(t, "api_key"))
.filter_map(|(_, t)| lookup_str(t, &["api_key"]))
.filter(|k| k != NO_KEY)
.collect(),
_ => Vec::new(),
Expand Down
5 changes: 1 addition & 4 deletions src-tauri/crates/tw-adopt/src/hermes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,7 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec<Edit> {
value: Val::s(value),
secret: false,
};
let mut v = vec![
plain("provider", "custom"),
plain("base_url", &format!("{}/v1", gw.base.trim_end_matches('/'))),
];
let mut v = vec![plain("provider", "custom"), plain("base_url", &gw.v1())];
if let Some(k) = &gw.key {
v.push(Edit {
path: at("api_key"),
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/crates/tw-adopt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,18 @@ pub mod wsl;
pub mod wslconfig;
pub mod yaml;
pub mod yamlval;

/// 现在,Unix 毫秒:备份的目录名、接管记录的时刻、客户端进程起了多久
pub(crate) fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}

/// 以 0 结尾的 UTF-16,交给 Windows 的 `…W` 函数(路径、注册表的键和值名)
#[cfg(windows)]
pub(crate) fn wide(s: impl AsRef<std::ffi::OsStr>) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
s.as_ref().encode_wide().chain(std::iter::once(0)).collect()
}
8 changes: 3 additions & 5 deletions src-tauri/crates/tw-adopt/src/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,9 @@ pub fn shape_in(text: &str) -> Shape {
/// **name 不留空**:opencode 的模型选择器显示的就是它,空着就是一行空白。
/// 同名的只写一次:JSON 对象里重复的键,各家解析器取哪一个说法不一。
pub fn models_val(models: &[ModelCard], shape: Shape) -> Val {
let mut seen = std::collections::HashSet::new();
Val::Obj(
models
.iter()
.filter(|m| seen.insert(m.id.as_str()))
crate::clients::unique(models)
.into_iter()
.map(|m| {
let mut fields = vec![("name".to_string(), Val::s(&m.id))];
fields.extend(specs(m, shape));
Expand Down Expand Up @@ -183,7 +181,7 @@ pub fn edits(gw: &Gateway, shape: Shape) -> Vec<Edit> {
},
Edit {
path: at(&[shape.settings(), "baseURL"]),
value: Val::s(format!("{}/v1", gw.base.trim_end_matches('/'))),
value: Val::s(gw.v1()),
secret: false,
},
];
Expand Down
7 changes: 2 additions & 5 deletions src-tauri/crates/tw-adopt/src/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
//! **默认模型不动。**和 opencode 一样只加一个 provider:Pi 的 `settings.json`、omp 的
//! `config.yml` 一个字节都不改,ThinkWatch 的模型由用户在 `/model` 里选。

use std::collections::HashSet;
use std::path::Path;

use tw_types::{Msg, msg};
Expand Down Expand Up @@ -129,11 +128,9 @@ pub fn api_for(model: &str) -> Api {
/// 网关答了的规格也写上(见 [`specs`]);没答的那一项不写,Pi 照它的默认值跑,omp 按模型名
/// 从它自带的目录里补。同名的只写一次,先后照网关答的。
pub fn models_val(gw: &Gateway) -> Val {
let mut seen = HashSet::new();
Val::Arr(
gw.models
.iter()
.filter(|m| seen.insert(m.id.as_str()))
crate::clients::unique(&gw.models)
.into_iter()
.map(|m| {
let api = api_for(&m.id);
let mut fields = vec![("id".to_string(), Val::s(&m.id))];
Expand Down
19 changes: 13 additions & 6 deletions src-tauri/crates/tw-adopt/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,13 @@ fn apply_file(plan: &Plan, backup_root: &Path) -> Result<(Applied, Option<String
.prior
.clone()
.unwrap_or_else(|| (applied.backup.display().to_string(), applied.created));
let rec = SidecarRecord::new(&plan.client, now_ms(), &backup, created, &plan.originals);
let rec = SidecarRecord::new(
&plan.client,
crate::now_ms(),
&backup,
created,
&plan.originals,
);
if let Err(e) = write_sidecar(&side, &rec) {
// 配置也退不回去的话,说的是那一件:它停在改过的样子上,而且没有记录
return Err(PlanError::Write(rollback(&applied).err().unwrap_or(e)));
Expand Down Expand Up @@ -1211,11 +1217,12 @@ pub(crate) fn lookup(v: &Val, path: &[&str]) -> Option<Val> {
Some(cur.clone())
}

fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
/// [`lookup`] 到的那一项,是字符串才算
pub(crate) fn lookup_str(v: &Val, path: &[&str]) -> Option<String> {
match lookup(v, path)? {
Val::Str(s) => Some(s),
_ => None,
}
}

/// 一份文件旁边的接管记录。**没有是 `None`;在却读不出来、解析不了是错**,不是「没有」。
Expand Down
Loading
Loading