feat: Kimi 设备授权登录(应用内置 kimi login)+ 外链打开统一走 Rust 命令(v0.6.7)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s

This commit is contained in:
KimiSwitch Dev committed 2026-08-01 14:49:14 +08:00
1 parent fc6789a941
commit ce282524db
12 files changed
+561 -10

No files matched your search

+248
View File
@@ -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
);
}
+18
View File
@@ -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>
);
}
+3 -4
View File
@@ -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}`);
});