feat: Kimi 设备授权登录(应用内置 kimi login)+ 外链打开统一走 Rust 命令(v0.6.7)
This commit is contained in:
1 parent
fc6789a941
commit
ce282524db
12 files changed
+561
-10
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1056,4 +1056,25 @@ pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
app.opener()
|
||||
.open_url(url, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kimi OAuth device-code sign-in (in-app replacement for `kimi login`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Step 1: ask auth.kimi.com for a user_code + verification URI.
|
||||
#[tauri::command]
|
||||
pub async fn kimi_oauth_start() -> Result<crate::oauth::DeviceAuthorization, String> {
|
||||
crate::oauth::start_device_authorization().await
|
||||
}
|
||||
|
||||
/// Step 2: poll the token endpoint until the user approves. On success the
|
||||
/// tokens are written to the CLI credentials file (`kimi login` no longer
|
||||
/// needed). Errors are transient (network); deterministic outcomes
|
||||
/// (pending / success / expired / denied / timeout) come back in the enum.
|
||||
#[tauri::command]
|
||||
pub async fn kimi_oauth_poll(
|
||||
device_code: String,
|
||||
interval: i64,
|
||||
) -> Result<crate::oauth::DevicePollStatus, String> {
|
||||
crate::oauth::poll_device_token(&device_code, interval).await
|
||||
}
|
||||
@@ -106,6 +106,8 @@ pub fn run() {
|
||||
commands::download_update,
|
||||
commands::open_installer,
|
||||
commands::open_external_url,
|
||||
commands::kimi_oauth_start,
|
||||
commands::kimi_oauth_poll,
|
||||
dashboard::get_paths,
|
||||
dashboard::get_prices,
|
||||
dashboard::get_summary,
|
||||
|
||||
+240
-1
@@ -21,8 +21,18 @@
|
||||
//! - right before writing back, the file is re-read once more — if the CLI
|
||||
//! refreshed meanwhile its newer tokens win (refresh tokens rotate on use,
|
||||
//! so clobbering the CLI's write would break its next refresh).
|
||||
//!
|
||||
//! v3 adds the in-app sign-in flow: a Device Code Flow (RFC 8628) identical
|
||||
//! to the official `kimi` CLI, so users can authorize Kimi from the app
|
||||
//! instead of running `kimi login` in a terminal. Flow:
|
||||
//! 1. `start_device_authorization()` → POST /api/oauth/device_authorization,
|
||||
//! returns user_code + device_code + verification_uri;
|
||||
//! 2. user opens the verification URI in a browser and approves;
|
||||
//! 3. `poll_device_token()` polls POST /api/oauth/token with the device_code
|
||||
//! grant until the tokens arrive, then writes them to the same
|
||||
//! `~/.kimi-code/credentials/kimi-code.json` file the CLI uses.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::kimi_code_io::kimi_code_config_dir;
|
||||
|
||||
@@ -32,11 +42,17 @@ const EXPIRY_LEEWAY_SECS: i64 = 30;
|
||||
|
||||
/// OAuth token endpoint (confirmed in the official kimi.exe binary).
|
||||
const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token";
|
||||
/// Device authorization endpoint (RFC 8628).
|
||||
const DEVICE_AUTHORIZATION_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/device_authorization";
|
||||
/// Public OAuth client id used by the official CLI (from kimi.exe).
|
||||
const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||
/// Refresh request timeout; refresh is rare, a bit more headroom than the 8s
|
||||
/// query default is fine.
|
||||
const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
/// Device grant type (RFC 8628).
|
||||
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
/// Total polling budget for the device flow, matching the official CLI.
|
||||
const POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OAuthCredentials {
|
||||
@@ -227,6 +243,200 @@ async fn refresh_credentials(creds: &OAuthCredentials) -> Result<String, String>
|
||||
Ok(token.access_token)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device Code Flow (RFC 8628) — in-app sign-in, mirrors the official CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response of POST /api/oauth/device_authorization.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct DeviceAuthorization {
|
||||
pub user_code: String,
|
||||
pub device_code: String,
|
||||
pub verification_uri: Option<String>,
|
||||
pub verification_uri_complete: Option<String>,
|
||||
pub expires_in: Option<i64>,
|
||||
pub interval: Option<i64>,
|
||||
}
|
||||
|
||||
/// Poll outcome, serialized back to the frontend so it can drive the dialog.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum DevicePollStatus {
|
||||
/// Keep polling with the returned interval (seconds).
|
||||
Pending { interval: i64 },
|
||||
/// Server asked to slow down; keep polling with interval + 5.
|
||||
SlowDown { interval: i64 },
|
||||
/// Tokens obtained and written to the credentials file.
|
||||
Success,
|
||||
/// Device code expired; the user must start over.
|
||||
Expired,
|
||||
/// User denied the authorization.
|
||||
AccessDenied,
|
||||
/// Polling budget exhausted.
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// `~/.kimi-code/device_id` — reused by the CLI so the app looks like the
|
||||
/// same device. Created on first use.
|
||||
fn device_id_path() -> std::path::PathBuf {
|
||||
kimi_code_config_dir().join("device_id")
|
||||
}
|
||||
|
||||
fn load_or_create_device_id() -> Option<String> {
|
||||
let path = device_id_path();
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
let id = content.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let id = format!("kimi-switch-{nanos:x}-{}", std::process::id());
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
// Best-effort write; a missing file just means the header is omitted.
|
||||
let _ = std::fs::write(&path, &id);
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Device identity headers matching the official CLI (`X-Msh-*`).
|
||||
fn identity_headers() -> Vec<(&'static str, String)> {
|
||||
let hostname = std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.unwrap_or_else(|_| "kimi-switch".to_string());
|
||||
let os_version = std::env::var("OS").unwrap_or_else(|_| std::env::consts::OS.to_string());
|
||||
let mut headers = vec![
|
||||
("X-Msh-Platform", "kimi_code_cli".to_string()),
|
||||
("X-Msh-Version", env!("CARGO_PKG_VERSION").to_string()),
|
||||
("X-Msh-Device-Name", hostname.clone()),
|
||||
(
|
||||
"X-Msh-Device-Model",
|
||||
format!("{} {}", std::env::consts::OS, hostname),
|
||||
),
|
||||
("X-Msh-Os-Version", os_version),
|
||||
];
|
||||
if let Some(device_id) = load_or_create_device_id() {
|
||||
headers.push(("X-Msh-Device-Id", device_id));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
/// Step 1 of the device flow: ask the server for a user_code + device_code.
|
||||
/// No user interaction required here; the frontend shows the code + URL.
|
||||
pub async fn start_device_authorization() -> Result<DeviceAuthorization, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REFRESH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
let mut req = client
|
||||
.post(DEVICE_AUTHORIZATION_ENDPOINT)
|
||||
.form(&[("client_id", CLIENT_ID)]);
|
||||
for (name, value) in identity_headers() {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device authorization request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let body: String = body.chars().take(200).collect();
|
||||
return Err(format!("Device authorization failed (HTTP {status}): {body}"));
|
||||
}
|
||||
resp.json::<DeviceAuthorization>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device authorization response: {e}"))
|
||||
}
|
||||
|
||||
/// Step 2 of the device flow: poll the token endpoint until the user
|
||||
/// approves (or the flow fails). On success the tokens are merged into the
|
||||
/// CLI credentials file, making `kimi login` unnecessary.
|
||||
pub async fn poll_device_token(
|
||||
device_code: &str,
|
||||
initial_interval: i64,
|
||||
) -> Result<DevicePollStatus, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REFRESH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
let deadline = std::time::Instant::now() + POLL_TIMEOUT;
|
||||
let mut interval = initial_interval.max(1);
|
||||
|
||||
loop {
|
||||
let mut req = client
|
||||
.post(TOKEN_ENDPOINT)
|
||||
.form(&[
|
||||
("grant_type", DEVICE_GRANT_TYPE),
|
||||
("client_id", CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
]);
|
||||
for (name, value) in identity_headers() {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(format!("Token polling request failed: {e}")),
|
||||
};
|
||||
|
||||
if resp.status().is_success() {
|
||||
let token: TokenResponse = match resp.json().await {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Err(format!("Failed to parse token response: {e}")),
|
||||
};
|
||||
persist_device_token(&token)?;
|
||||
return Ok(DevicePollStatus::Success);
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let error: Option<String> = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from));
|
||||
match error.as_deref() {
|
||||
Some("authorization_pending") => {}
|
||||
Some("slow_down") => interval += 5,
|
||||
Some("expired_token") => return Ok(DevicePollStatus::Expired),
|
||||
Some("access_denied") => return Ok(DevicePollStatus::AccessDenied),
|
||||
Some(other) => {
|
||||
let body: String = body.chars().take(200).collect();
|
||||
return Err(format!("Token polling error ({other}): {body}"));
|
||||
}
|
||||
None => {
|
||||
let body: String = body.chars().take(200).collect();
|
||||
return Err(format!("Token polling failed (HTTP {status}): {body}"));
|
||||
}
|
||||
}
|
||||
|
||||
if std::time::Instant::now() + std::time::Duration::from_secs(interval as u64) >= deadline {
|
||||
return Ok(DevicePollStatus::Timeout);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(interval as u64)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a freshly obtained token set into the CLI credentials file,
|
||||
/// preserving unrelated fields and using the same snake_case shape.
|
||||
fn persist_device_token(token: &TokenResponse) -> Result<(), String> {
|
||||
let path = credentials_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let current = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let merged = merge_token_response(¤t, token);
|
||||
std::fs::write(&path, merged)
|
||||
.map_err(|e| format!("Failed to write Kimi credentials: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -330,4 +540,33 @@ mod tests {
|
||||
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(v["refresh_token"], "old-refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_authorization_serializes_snake_case() {
|
||||
let auth = DeviceAuthorization {
|
||||
user_code: "ABC-DEF".to_string(),
|
||||
device_code: "dev-1".to_string(),
|
||||
verification_uri: Some("https://auth.kimi.com/device".to_string()),
|
||||
verification_uri_complete: None,
|
||||
expires_in: Some(1800),
|
||||
interval: Some(5),
|
||||
};
|
||||
let v = serde_json::to_value(&auth).unwrap();
|
||||
assert_eq!(v["user_code"], "ABC-DEF");
|
||||
assert_eq!(v["device_code"], "dev-1");
|
||||
assert_eq!(v["verification_uri_complete"], serde_json::Value::Null);
|
||||
assert_eq!(v["interval"], 5);
|
||||
assert!(v.get("userCode").is_none(), "must be snake_case for the frontend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_poll_status_serializes_snake_case() {
|
||||
// Internally tagged: {"status":"pending","interval":5} — easy for the
|
||||
// frontend to switch on.
|
||||
let pending = serde_json::to_value(DevicePollStatus::Pending { interval: 5 }).unwrap();
|
||||
assert_eq!(pending["status"], "pending");
|
||||
assert_eq!(pending["interval"], 5);
|
||||
let success = serde_json::to_value(DevicePollStatus::Success).unwrap();
|
||||
assert_eq!(success["status"], "success");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "../i18n";
|
||||
|
||||
interface DeviceAuthorization {
|
||||
user_code: string;
|
||||
device_code: string;
|
||||
verification_uri: string | null;
|
||||
verification_uri_complete: string | null;
|
||||
expires_in: number | null;
|
||||
interval: number | null;
|
||||
}
|
||||
|
||||
type PollStatus =
|
||||
| { status: "pending"; interval: number }
|
||||
| { status: "slow_down"; interval: number }
|
||||
| { status: "success" }
|
||||
| { status: "expired" }
|
||||
| { status: "access_denied" }
|
||||
| { status: "timeout" };
|
||||
|
||||
interface KimiOAuthDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Kimi device-code sign-in dialog (in-app `kimi login`). */
|
||||
export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [auth, setAuth] = useState<DeviceAuthorization | null>(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const activeRef = useRef(false);
|
||||
const intervalRef = useRef(5);
|
||||
|
||||
// Start a fresh device authorization when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
activeRef.current = true;
|
||||
setAuth(null);
|
||||
setPolling(false);
|
||||
setDone(false);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
intervalRef.current = 5;
|
||||
invoke<DeviceAuthorization>("kimi_oauth_start")
|
||||
.then((a) => {
|
||||
if (!activeRef.current) return;
|
||||
setAuth(a);
|
||||
if (a.interval && a.interval > 0) intervalRef.current = a.interval;
|
||||
setPolling(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!activeRef.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Poll the token endpoint while the user authorizes in the browser.
|
||||
useEffect(() => {
|
||||
if (!open || !polling || !auth) return;
|
||||
let cancelled = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled || !activeRef.current) return;
|
||||
try {
|
||||
const res = await invoke<PollStatus>("kimi_oauth_poll", {
|
||||
deviceCode: auth.device_code,
|
||||
interval: intervalRef.current,
|
||||
});
|
||||
if (cancelled) return;
|
||||
switch (res.status) {
|
||||
case "pending":
|
||||
case "slow_down":
|
||||
if (res.interval > 0) intervalRef.current = res.interval;
|
||||
break;
|
||||
case "success":
|
||||
setPolling(false);
|
||||
setDone(true);
|
||||
setTimeout(() => {
|
||||
if (activeRef.current) onClose();
|
||||
}, 1600);
|
||||
return;
|
||||
case "expired":
|
||||
case "access_denied":
|
||||
case "timeout":
|
||||
setPolling(false);
|
||||
setError(
|
||||
res.status === "expired"
|
||||
? t("kimiOAuthExpired")
|
||||
: res.status === "access_denied"
|
||||
? t("kimiOAuthDenied")
|
||||
: t("kimiOAuthTimeout")
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(t("kimiOAuthNetworkError"));
|
||||
}
|
||||
// Schedule the next poll (also on network errors — keep retrying).
|
||||
setTimeout(() => {
|
||||
if (!cancelled && activeRef.current && polling) void tick();
|
||||
}, intervalRef.current * 1000);
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `polling` and `auth` gate the loop; restart polling explicitly via
|
||||
// startPolling when retrying.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, polling, auth]);
|
||||
|
||||
const restart = () => {
|
||||
setAuth(null);
|
||||
setPolling(false);
|
||||
setDone(false);
|
||||
setError(null);
|
||||
intervalRef.current = 5;
|
||||
invoke<DeviceAuthorization>("kimi_oauth_start")
|
||||
.then((a) => {
|
||||
if (!activeRef.current) return;
|
||||
setAuth(a);
|
||||
if (a.interval && a.interval > 0) intervalRef.current = a.interval;
|
||||
setPolling(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!activeRef.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
};
|
||||
|
||||
const copyCode = () => {
|
||||
if (!auth) return;
|
||||
navigator.clipboard.writeText(auth.user_code).catch(() => {});
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
// Open the authorization page via the Rust-side opener command (bypasses the
|
||||
// JS plugin scope, same reliable path the provider website links use);
|
||||
// fall back to a new tab when even that fails.
|
||||
const openAuthPage = () => {
|
||||
if (!auth) return;
|
||||
const url = auth.verification_uri_complete || auth.verification_uri;
|
||||
if (!url) return;
|
||||
invoke("open_external_url", { url }).catch(() => {
|
||||
const w = window.open(url, "_blank");
|
||||
if (!w) console.error(`failed to open ${url}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h3 className="font-medium text-content-primary">{t("kimiOAuthLogin")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-content-muted hover:text-content-primary text-lg leading-none"
|
||||
aria-label={t("kimiOAuthCancel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
{done && (
|
||||
<div className="rounded-lg bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 text-sm px-3 py-2">
|
||||
✓ {t("kimiOAuthSuccess")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !done && (
|
||||
<div className="rounded-lg bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 text-sm px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{auth && !done && (
|
||||
<>
|
||||
{/* user code + copy */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code className="text-2xl font-bold tracking-widest tabular-nums text-content-primary select-all">
|
||||
{auth.user_code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCode}
|
||||
className="px-2.5 py-1 text-xs rounded border border-border hover:bg-hover-2 text-content-muted transition-colors"
|
||||
>
|
||||
{copied ? t("kimiOAuthCodeCopied") : t("kimiOAuthCopyCode")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAuthPage}
|
||||
className="w-full px-3 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
{t("kimiOAuthOpenPage")}
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-content-muted text-center">
|
||||
{t("kimiOAuthWaiting")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!auth && !done && (
|
||||
<div className="h-3.5 w-2/3 rounded bg-hover-2 animate-pulse mx-auto" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
{error && !done && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={restart}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
|
||||
>
|
||||
{t("kimiOAuthRetry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={polling}
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-600 text-white hover:bg-gray-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("kimiOAuthCancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
||||
import { getIconMetadata } from "../icons/extracted/metadata";
|
||||
import { AgentSettingsPanel } from "./AgentSettingsPanel";
|
||||
import { KimiOAuthDialog } from "./KimiOAuthDialog";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { IconPicker } from "./IconPicker";
|
||||
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
|
||||
@@ -117,6 +118,7 @@ export function ProviderEdit({
|
||||
const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [kimiOAuthOpen, setKimiOAuthOpen] = useState(false);
|
||||
|
||||
// Preset this provider was created from (if any) — provides the
|
||||
// "Get API Key" / referral links shown under the key input.
|
||||
@@ -270,6 +272,17 @@ export function ProviderEdit({
|
||||
{t("managedProvider")}
|
||||
</label>
|
||||
</div>
|
||||
{provider.managed && (
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKimiOAuthOpen(true)}
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
{t("kimiOAuthLogin")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -439,6 +452,11 @@ export function ProviderEdit({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<KimiOAuthDialog
|
||||
open={kimiOAuthOpen}
|
||||
onClose={() => setKimiOAuthOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useTranslation, type Language } from "../i18n";
|
||||
import { useTheme, type Theme } from "../hooks/useTheme";
|
||||
import type { UpdateInfo } from "../hooks/useUpdateCheck";
|
||||
@@ -110,10 +109,10 @@ export function SettingsModal({
|
||||
invoke("open_installer", { path: downloadedPath }).catch(() => {});
|
||||
};
|
||||
|
||||
// Open an external link in the system browser. Falls back to a new tab
|
||||
// when the opener plugin call fails, so a broken click never stays silent.
|
||||
// Open an external link via the Rust-side opener command (bypasses the JS
|
||||
// plugin scope); fall back to a new tab when even that fails.
|
||||
const openExternal = (url: string) => {
|
||||
openUrl(url).catch(() => {
|
||||
invoke("open_external_url", { url }).catch(() => {
|
||||
const w = window.open(url, "_blank");
|
||||
if (!w) console.error(`failed to open ${url}`);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,18 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
notePlaceholder: "e.g. company account",
|
||||
officialUrl: "Official URL",
|
||||
managedProvider: "Managed provider (no credentials needed)",
|
||||
kimiOAuthLogin: "Sign in with Kimi",
|
||||
kimiOAuthOpenPage: "Open authorization page",
|
||||
kimiOAuthCopyCode: "Copy code",
|
||||
kimiOAuthCodeCopied: "Copied",
|
||||
kimiOAuthWaiting: "Enter the code on the page that opens, waiting for authorization…",
|
||||
kimiOAuthSuccess: "Authorized — credentials written for Kimi login",
|
||||
kimiOAuthExpired: "Code expired, please start over",
|
||||
kimiOAuthDenied: "Authorization denied",
|
||||
kimiOAuthTimeout: "Authorization timed out, please start over",
|
||||
kimiOAuthRetry: "Restart",
|
||||
kimiOAuthCancel: "Cancel",
|
||||
kimiOAuthNetworkError: "Network error, retrying…",
|
||||
apiSettings: "API Settings",
|
||||
apiFormat: "API Format",
|
||||
authField: "Auth Field",
|
||||
|
||||
@@ -54,6 +54,18 @@ export const zhTranslations = {
|
||||
notePlaceholder: "例如:公司专用账号",
|
||||
officialUrl: "官网链接",
|
||||
managedProvider: "托管供应商(无需凭证)",
|
||||
kimiOAuthLogin: "Kimi 授权登录",
|
||||
kimiOAuthOpenPage: "打开授权页",
|
||||
kimiOAuthCopyCode: "复制授权码",
|
||||
kimiOAuthCodeCopied: "已复制",
|
||||
kimiOAuthWaiting: "请在打开的页面中输入授权码,等待授权中…",
|
||||
kimiOAuthSuccess: "授权成功,已写入 Kimi 登录凭据",
|
||||
kimiOAuthExpired: "授权码已过期,请重新开始",
|
||||
kimiOAuthDenied: "授权被拒绝",
|
||||
kimiOAuthTimeout: "授权超时,请重新开始",
|
||||
kimiOAuthRetry: "重新开始",
|
||||
kimiOAuthCancel: "取消",
|
||||
kimiOAuthNetworkError: "网络错误,正在重试…",
|
||||
apiSettings: "API 设置",
|
||||
apiFormat: "API 格式",
|
||||
authField: "认证字段",
|
||||
|
||||
Reference in new issue
Block a user