Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 2 deletions scripts/shots/mock/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ interface Commands {
copy_key: [void, void];
copy_gateway_base: [void, void];
copy_client_endpoint: [void, void];
copy_chatgpt_code: [void, void];
copy_chatgpt_login: [void, void];
reveal_client_config: [void, void];
}

Expand Down Expand Up @@ -170,7 +170,7 @@ const COMMANDS: Table = {
copy_key: done,
copy_gateway_base: done,
copy_client_endpoint: done,
copy_chatgpt_code: done,
copy_chatgpt_login: done,
reveal_client_config: done,
};

Expand Down
61 changes: 43 additions & 18 deletions src-tauri/src/chatgpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
//! 登录本身在 core:它给出授权地址或者设备码、等授权完成、把凭据写进配置。这一层做
//! core 做不到的两件事 —— **打开浏览器**,以及授权完成后**把应用带回前台**。
//!
//! 授权地址和登录码存在这里而不是交给界面:界面拿着地址,就等于多了一条「界面传来
//! 一个地址、应用去打开它」的路径;拿着码,就等于多了一个往剪贴板里写任意内容的口子。
//! 重新打开授权页、复制登录码都只按登录 ID 找。**只有设备码要给界面看**,因为用户
//! 得把它念出来或者抄到另一台设备上。
//! 地址和登录码**界面只拿来显示**:用户要看得见登录链接、验证网址和设备码,才能抄到
//! 别的浏览器或者别的设备上。打开、复制都在这里做,而且**只按登录 ID 找**这里存着的
//! 那一份 —— 「界面传一个地址进来、应用去打开它」和「界面传一段文字进来、应用写进
//! 剪贴板」这两条路都不存在。

use std::sync::Mutex;

Expand All @@ -24,32 +24,44 @@ static PENDING: Mutex<Option<Pending>> = Mutex::new(None);

struct Pending {
id: String,
/// 在这台电脑上登录:要打开的授权地址
/// 要打开的网页:浏览器登录是授权地址,设备码登录是输码的验证网址
url: Option<String>,
/// 在其他设备上登录:要输的那个码
code: Option<String>,
}

/// 一次登录里界面该知道的部分。**授权地址不在其中**
/// 一次登录里界面该知道的部分。地址只拿来显示,打开和复制都按 ID 回到这里
#[derive(serde::Serialize)]
pub struct Login {
id: String,
authorize_url: Option<String>,
user_code: Option<String>,
verification_url: Option<String>,
expires_in_secs: u64,
}

/// 复制一次登录里的哪一项
#[derive(serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Item {
/// 浏览器登录的授权地址,或设备码登录的验证网址
Url,
/// 设备码
Code,
}

/// `mode`:`browser` 在这台电脑上开浏览器,`device` 拿一个码去别处输。
///
/// **浏览器那条路在这里就把页面打开**:让用户再点一次「打开授权页」,中间那一步
/// 没有任何意义。
/// **浏览器那条路默认在这里就把页面打开**:让用户再点一次「打开授权页」,中间那一步
/// 没有任何意义。`open: false` 是用户要把登录链接复制到别的浏览器里:不开默认浏览器。
#[tauri::command]
pub async fn start_chatgpt_login(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
name: Option<String>,
proxy: Option<String>,
mode: Option<tw_api::ChatgptLoginMode>,
open: Option<bool>,
) -> Out<Login> {
let login = state
.control
Expand All @@ -67,40 +79,53 @@ pub async fn start_chatgpt_login(
if let Ok(mut g) = PENDING.lock() {
*g = Some(Pending {
id: login.id.clone(),
url: login.authorize_url.clone(),
url: login
.authorize_url
.clone()
.or_else(|| login.verification_url.clone()),
code: login.user_code.clone(),
});
}
if let Some(url) = &login.authorize_url {
if let Some(url) = &login.authorize_url
&& open != Some(false)
{
open_page(&app, url)?;
}
Ok(Login {
id: login.id,
authorize_url: login.authorize_url,
user_code: login.user_code,
verification_url: login.verification_url,
expires_in_secs: login.expires_in_secs,
})
}

/// 浏览器被关掉、或者授权页打不开时再打开一次。**只认还在等的那一次**
/// 再打开一次登录的网页:浏览器登录是授权页(浏览器被关掉、或者没打开时),设备码
/// 登录是输码的验证网址。**只认还在等的那一次**
#[tauri::command]
pub async fn reopen_chatgpt_login(app: tauri::AppHandle, id: String) -> Out<()> {
let url = pending(&id, |p| p.url.clone())?;
Ok(open_page(&app, &url)?)
}

/// 把登录码放进剪贴板。
/// 把登录链接(或验证网址)、设备码放进剪贴板。
///
/// **码由这里给出,不从界面传进来**:那样等于给 webview 开一个往剪贴板里写任意内容
/// **内容由这里给出,不从界面传进来**:那样等于给 webview 开一个往剪贴板里写任意内容
/// 的口子。在 Rust 这边写也不用看 webview 给不给剪贴板权限 —— 一个「复制」按钮
/// 唯一不能有的表现就是点了没反应。
#[tauri::command]
pub fn copy_chatgpt_code(app: tauri::AppHandle, id: String) -> Out<()> {
pub fn copy_chatgpt_login(app: tauri::AppHandle, id: String, item: Item) -> Out<()> {
let text = match item {
Item::Url => pending(&id, |p| p.url.clone())?,
Item::Code => pending(&id, |p| p.code.clone())?,
};
Ok(copy_text(&app, text)?)
}

/// 写剪贴板。Z.ai 的登录链接也走这里
pub(crate) fn copy_text(app: &tauri::AppHandle, text: String) -> Result<(), String> {
use tauri_plugin_clipboard_manager::ClipboardExt;
let code = pending(&id, |p| p.code.clone())?;
app.clipboard()
.write_text(code)
.map_err(|e| e.to_string().into())
app.clipboard().write_text(text).map_err(|e| e.to_string())
}

/// 还在等的那次登录里的某一项。**只认还在等的那一次**
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,11 @@ pub fn run() {
keys::key_usage,
chatgpt::start_chatgpt_login,
chatgpt::reopen_chatgpt_login,
chatgpt::copy_chatgpt_code,
chatgpt::copy_chatgpt_login,
chatgpt::cancel_chatgpt_login,
zai::start_zai_login,
zai::reopen_zai_login,
zai::copy_zai_login,
zai::cancel_zai_login,
settings::app_info,
updater::update_state,
Expand Down
36 changes: 28 additions & 8 deletions src-tauri/src/zai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
//! (见 ChatGPT 那条路的 `handle_return`:那是因为回调页是 core 自己发的)。所以登录
//! 成没成只有界面在轮询里知道。
//!
//! 授权地址存在这里而不是交给界面:界面拿着地址,就等于多了一条「界面传来一个地址、
//! 应用去打开它」的路径。重新打开授权页只按登录 ID 找。
//! 授权地址**界面只拿来显示**(用户要能把它复制到别的浏览器里)。打开、复制都在这里
//! 做,只按登录 ID 找这里存着的那一份 —— 「界面传一个地址进来、应用去打开它」这条路
//! 不存在。

use std::sync::Mutex;

Expand All @@ -26,23 +27,26 @@ struct Pending {
url: String,
}

/// 一次登录里界面该知道的部分。**授权地址不在其中**
/// 一次登录里界面该知道的部分。地址只拿来显示,打开和复制都按 ID 回到这里
#[derive(serde::Serialize)]
pub struct Login {
id: String,
authorize_url: String,
expires_in_secs: u64,
}

/// `family`:`zai`(api.z.ai)或 `bigmodel`(open.bigmodel.cn)。
///
/// **这里就把授权页打开**:让用户再点一次「打开授权页」,中间那一步没有任何意义。
/// **默认在这里就把授权页打开**:让用户再点一次「打开授权页」,中间那一步没有任何
/// 意义。`open: false` 是用户要把登录链接复制到别的浏览器里:不开默认浏览器。
#[tauri::command]
pub async fn start_zai_login(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
family: Option<tw_api::ZaiFamily>,
name: Option<String>,
proxy: Option<String>,
open: Option<bool>,
) -> Out<Login> {
let login = state
.control
Expand All @@ -62,17 +66,34 @@ pub async fn start_zai_login(
url: login.authorize_url.clone(),
});
}
crate::chatgpt::open_page(&app, &login.authorize_url)?;
if open != Some(false) {
crate::chatgpt::open_page(&app, &login.authorize_url)?;
}
Ok(Login {
id: login.id,
authorize_url: login.authorize_url,
expires_in_secs: login.expires_in_secs,
})
}

/// 浏览器被关掉、或者授权页打不开时再打开一次。**只认还在等的那一次**
#[tauri::command]
pub async fn reopen_zai_login(app: tauri::AppHandle, id: String) -> Out<()> {
let url = PENDING
let url = pending_url(&id)?;
Ok(crate::chatgpt::open_page(&app, &url)?)
}

/// 把登录链接放进剪贴板。**链接由这里给出,不从界面传进来**(见 ChatGPT 那边的
/// `copy_chatgpt_login`)
#[tauri::command]
pub fn copy_zai_login(app: tauri::AppHandle, id: String) -> Out<()> {
let url = pending_url(&id)?;
Ok(crate::chatgpt::copy_text(&app, url)?)
}

/// 还在等的那次登录的授权地址。**只认还在等的那一次**
fn pending_url(id: &str) -> Result<String, String> {
PENDING
.lock()
.ok()
.and_then(|g| g.as_ref().filter(|p| p.id == id).map(|p| p.url.clone()))
Expand All @@ -82,8 +103,7 @@ pub async fn reopen_zai_login(app: tauri::AppHandle, id: String) -> Out<()> {
"This sign-in has ended; start a new one"
)
.to_string()
})?;
Ok(crate::chatgpt::open_page(&app, &url)?)
})
}

#[tauri::command]
Expand Down
2 changes: 0 additions & 2 deletions src/connection/remote.i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export const remoteText = messages(
// 账号登录、环境变量、系统代理
deviceOnly: (name: string) =>
`浏览器登录完成后会回到运行 core 的那台机器,而当前连接的是 ${name},因此只能用设备码登录。`,
signInWithCode: "用设备码登录",
headersHint: "随每个请求发送。值中的 ${变量名} 读取服务器上 core 进程的环境变量。",
keyHint: "${变量名} 读取服务器上 core 进程的环境变量。",
profileHint:
Expand Down Expand Up @@ -84,7 +83,6 @@ export const remoteText = messages(

deviceOnly: (name: string) =>
`A browser sign-in returns to the machine that runs core, and this app is connected to ${name}, so only the device code sign-in is available.`,
signInWithCode: "Sign in with a device code",
headersHint: "Sent with every request. ${NAME} in a value reads an environment variable of the core process on the server.",
keyHint: "${NAME} reads an environment variable of the core process on the server.",
profileHint:
Expand Down
19 changes: 16 additions & 3 deletions src/generated/tw-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ export type BalanceBy = "weights" | "latency" | "health" | "latency-health";
*/
export type BaseVersion = { base_version?: string | null, };

/**
* 一家上游的 `balance:` 写的是什么。
*/
export type BalanceSetting = "auto" | "off" | "openrouter" | "deepseek" | "moonshot" | "sub2api" | "newapi" | "thinkwatch";

/**
* 一个上游怎么收钱。
*/
Expand Down Expand Up @@ -3379,11 +3384,15 @@ pricing?: string | null,
/**
* 同时最多发给这家几个请求,1 到 1000。不给就是不限
*/
max_concurrent?: number | null,
max_concurrent?: number | null,
/**
* 停用
*/
disabled: boolean, };
disabled: boolean,
/**
* 余额从哪儿读。不给就是 `auto`
*/
balance?: BalanceSetting | null, };

/**
* 一个上游的模型清单。
Expand Down Expand Up @@ -3600,7 +3609,11 @@ pricing: string | null,
/**
* 同时最多发给这家几个请求。不限是空
*/
max_concurrent?: number | null, };
max_concurrent?: number | null,
/**
* 配置里的 `balance:`,原样:编辑对话框保存时交回去([`ProviderInput::balance`])
*/
balance_setting?: BalanceSetting, };

/**
* 代理的用户名和密码。
Expand Down
17 changes: 3 additions & 14 deletions src/launch/LaunchScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { troubleText } from "./trouble.i18n";
import { launchText } from "./LaunchScreen.i18n";
import { launchPhase } from "./phase";
import { connText } from "@/connection/connection.i18n";
import { TW_STROKES } from "@/ui/tw-strokes";

/**
* 至少停多久。**冷启动每次都停**:秒开也不跳过,四笔要画完、亮一下。
Expand All @@ -25,20 +26,8 @@ const SLOW_MS = 5_000;
*/
const RestartButton = lazy(() => import("./RestartButton"));

/**
* TW 四笔和各自的长度。坐标和应用图标(src-tauri/icons/render.py 的 STROKES)
* 是同一套。
*
* **长度写死,不用 `pathLength="1"` 归一。**虚线按归一的长度算,WebKit 各个
* 版本的支持不一样;写死的长度哪儿都一样。V 的一边是 √(4.5² + 8²)
*/
const V = Math.hypot(4.5, 8);
const STROKES: [string, number][] = [
["M7 9H25", 18],
["M16 9V17", 8],
["M7 17L11.5 25L16 17", 2 * V],
["M16 17L20.5 25L25 17", 2 * V],
];
/** TW 四笔(见 `@/ui/tw-strokes`:那个文件不引任何东西,不会把启动画面这一块撑大) */
const STROKES = TW_STROKES;

/**
* 启动画面:开窗到主界面能用之间的那一面。
Expand Down
29 changes: 29 additions & 0 deletions src/ui/app-mark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
import { TW_STROKES } from "./tw-strokes";

/**
* ThinkWatch 自己的标志,和厂商标志一样**单色**、跟着文字色走(`@/ui/logos`),放进
* 同一种方片里一列对得齐。启动画面上那个带霓虹渐变、一笔一笔画出来的是同样四笔。
*/
export function AppMark({ size = 16, className }: { size?: number; className?: string }) {
return (
<svg
data-slot="logo"
// 四笔连笔帽落在 (5.7, 7.7)–(26.3, 26.3):正方形的画框,图形居中
viewBox="5 6 22 22"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={2.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={cn("shrink-0", className)}
>
{TW_STROKES.map(([d]) => (
<path key={d} d={d} />
))}
</svg>
);
}
9 changes: 9 additions & 0 deletions src/ui/copy-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,23 @@ export function CopyButton({
onCopy,
label,
disabled,
flashAtStart = false,
}: {
onCopy: () => Promise<void>;
label?: string;
disabled?: boolean;
/** 一出现就是「已复制」:内容在它出现之前已经复制过了(「复制登录链接」开始的那次登录) */
flashAtStart?: boolean;
}) {
const common = useText(commonText);
const [copied, flash] = useFlash();
const [busy, setBusy] = useState(false);
const firstFlash = useRef(flashAtStart);
useEffect(() => {
if (firstFlash.current) flash();
// 只在出现时亮一下
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Button
variant="outline"
Expand Down
Loading
Loading