diff --git a/internal/cloud/opencsg.go b/internal/cloud/opencsg.go index 76e88a8..ce7152b 100644 --- a/internal/cloud/opencsg.go +++ b/internal/cloud/opencsg.go @@ -17,7 +17,7 @@ import ( const ( DefaultBaseURL = "https://ai.space.opencsg.com" - DefaultLoginURL = "https://iam.opencsg.com/login/oauth/authorize?client_id=d623c957e69976c8a7a8&response_type=code&redirect_uri=https://hub.opencsg.com/api/v1/callback/casdoor&scope=read&state=casdoor" + DefaultLoginURL = "https://iam.opencsg.com/login/oauth/authorize?client_id=d623c957e69976c8a7a8&response_type=code&redirect_uri=https://hub.opencsg.com/api/v1/callback/casdoor&scope=read&state=lite" DefaultAccessTokenURL = "https://opencsg.com/settings/access-token" defaultCacheTTL = 5 * time.Minute cloudModelListPage = "1" diff --git a/internal/config/config.go b/internal/config/config.go index 024c996..31b14bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ const ( DefaultListenAddr = ":11435" DefaultDesktopAPIAddr = "127.0.0.1:11436" DefaultDesktopAPIBindAddr = "0.0.0.0:11436" + DefaultAuthCallbackAddr = "127.0.0.1:11437" DefaultCloudProviderName = "csghub" DefaultMarketplaceSource = "opencsg" DefaultHuggingFaceEndpoint = "https://huggingface.co" @@ -88,6 +89,7 @@ type Config struct { DesktopAPIAddr string `json:"-"` DesktopAPIBindAddr string `json:"-"` DesktopAPIBoundAddr string `json:"-"` + AuthCallbackAddr string `json:"-"` } func (c *Config) EffectiveListenAddr() string { diff --git a/internal/server/handlers_cloud.go b/internal/server/handlers_cloud.go index f06b6ce..41b01bb 100644 --- a/internal/server/handlers_cloud.go +++ b/internal/server/handlers_cloud.go @@ -3,6 +3,8 @@ package server import ( "context" "encoding/json" + "io" + "log" "net/http" "strings" @@ -59,16 +61,54 @@ func (s *Server) handleCloudAuthTokenSave(w http.ResponseWriter, r *http.Request return } - s.cfg.Token = token - if err := config.Save(s.cfg); err != nil { + if err := s.saveCloudAccessToken(token); err != nil { writeError(w, http.StatusInternalServerError, "saving token: "+err.Error()) return } + + writeJSON(w, http.StatusOK, s.cloudAuthStatus(r.Context())) +} + +func (s *Server) saveCloudAccessToken(token string) error { + token = strings.TrimSpace(token) + s.cfg.Token = token + if err := config.Save(s.cfg); err != nil { + return err + } if s.cloud != nil { s.cloud.SetAccessToken(token) } + return nil +} + +// handleCloudAuthCallback is the browser redirect target for the "lite" SSO +// flow. The user service redirects here after a successful web login with the +// access token in the query string; we persist it and return a small page that +// closes itself so the waiting web UI can pick up the new login state. +func (s *Server) handleCloudAuthCallback(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.URL.Query().Get("token")) + if token == "" { + writeCloudAuthCallbackPage(w, false, "missing_token") + return + } + if err := s.saveCloudAccessToken(token); err != nil { + log.Printf("cloud auth callback: saving token: %v", err) + writeCloudAuthCallbackPage(w, false, "save_failed") + return + } + writeCloudAuthCallbackPage(w, true, "") +} - writeJSON(w, http.StatusOK, s.cloudAuthStatus(r.Context())) +func writeCloudAuthCallbackPage(w http.ResponseWriter, success bool, errCode string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + w.WriteHeader(http.StatusOK) + if success { + _, _ = io.WriteString(w, `Login complete

Login complete. You can close this window and return to csglite.

`) + return + } + _, _ = io.WriteString(w, `Login failed

Login failed (`+errCode+`). Please try again.

`) } func (s *Server) handleCloudAuthTokenDelete(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/handlers_cloud_test.go b/internal/server/handlers_cloud_test.go index c367eb7..666bdea 100644 --- a/internal/server/handlers_cloud_test.go +++ b/internal/server/handlers_cloud_test.go @@ -224,6 +224,49 @@ func TestHandleCloudAuthTokenSaveAndDelete(t *testing.T) { } } +func TestHandleCloudAuthCallback(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + s := newTestServer(t) + + saveReq := httptest.NewRequest(http.MethodGet, "/api/cloud/auth/callback?token=%20test-token%20", nil) + w := httptest.NewRecorder() + s.handleCloudAuthCallback(w, saveReq) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", ct) + } + if !strings.Contains(w.Body.String(), "Login complete") { + t.Fatalf("body = %q, want success page", w.Body.String()) + } + if s.cfg.Token != "test-token" { + t.Fatalf("saved token = %q, want %q", s.cfg.Token, "test-token") + } +} + +func TestHandleCloudAuthCallbackMissingToken(t *testing.T) { + s := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/cloud/auth/callback", nil) + w := httptest.NewRecorder() + s.handleCloudAuthCallback(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if !strings.Contains(w.Body.String(), "Login failed") || !strings.Contains(w.Body.String(), "missing_token") { + t.Fatalf("body = %q, want failure page with missing_token", w.Body.String()) + } +} + func TestHandleCloudAPIKeySaveAndDelete(t *testing.T) { config.Reset() t.Cleanup(config.Reset) diff --git a/internal/server/routes.go b/internal/server/routes.go index 93100a7..a869996 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -217,6 +217,12 @@ func (s *Server) externalAPIRoutes() http.Handler { )) } +func (s *Server) authCallbackRoutes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/cloud/auth/callback", s.handleCloudAuthCallback) + return mux +} + func desktopExternalAPIMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Origin") != "" { diff --git a/internal/server/server.go b/internal/server/server.go index b6b093d..6f32214 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -160,10 +160,11 @@ type Server struct { appManager *apps.Manager sourceSwitches *apps.SourceSwitchManager appShells *aiAppShellManager - cloud *cloud.Service - http *http.Server - externalHTTP *http.Server - logBuf *LogBuffer + cloud *cloud.Service + http *http.Server + externalHTTP *http.Server + authCallbackHTTP *http.Server + logBuf *LogBuffer mu sync.RWMutex engines map[string]*managedEngine @@ -350,6 +351,16 @@ func New(cfg *config.Config, version string) *Server { return s } +// authCallbackAddr returns the loopback address dedicated to receiving the +// "lite" SSO redirect, so the browser can hand the access token back to this +// process on a stable port regardless of the main listener address. +func (s *Server) authCallbackAddr() string { + if s != nil && strings.TrimSpace(s.cfg.AuthCallbackAddr) != "" { + return strings.TrimSpace(s.cfg.AuthCallbackAddr) + } + return config.DefaultAuthCallbackAddr +} + func resolveCloudURL(cfg *config.Config) string { if u := strings.TrimSpace(cfg.AIGatewayURL); u != "" { return u @@ -386,6 +397,19 @@ func (s *Server) Run(ctx context.Context) error { s.cfg.DesktopAPIBoundAddr = externalListener.Addr().String() } + authCallbackListener, err := net.Listen("tcp", s.authCallbackAddr()) + if err != nil { + log.Printf("cloud auth callback listener unavailable on %s: %v", s.authCallbackAddr(), err) + } else { + defer authCallbackListener.Close() + s.authCallbackHTTP = &http.Server{ + Handler: s.authCallbackRoutes(), + ReadHeaderTimeout: 30 * time.Second, + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } + } + go s.startEvictor(ctx) go s.refreshCloudModelsOnStartup(ctx) curationCtx, cancelCuration := context.WithCancel(ctx) @@ -405,7 +429,7 @@ func (s *Server) Run(ctx context.Context) error { }() } - errCh := make(chan error, 2) + errCh := make(chan error, 3) if s.cfg.DesktopMode { baseURL := "http://" + boundAddr ready := desktopReady{ @@ -444,6 +468,14 @@ func (s *Server) Run(ctx context.Context) error { } }() } + if s.authCallbackHTTP != nil { + go func() { + log.Printf(" Auth callback: http://%s", s.authCallbackAddr()) + if err := s.authCallbackHTTP.Serve(authCallbackListener); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + } select { case err := <-errCh: @@ -480,6 +512,11 @@ func (s *Server) shutdownHTTPServers(ctx context.Context) error { if s.externalHTTP != nil { externalErr = s.externalHTTP.Shutdown(ctx) } + if s.authCallbackHTTP != nil { + if err := s.authCallbackHTTP.Shutdown(ctx); err != nil && externalErr == nil { + externalErr = err + } + } internalErr := s.http.Shutdown(ctx) if internalErr != nil { return internalErr diff --git a/internal/server/static/openapi/local-api.json b/internal/server/static/openapi/local-api.json index 511fed5..fc3b696 100644 --- a/internal/server/static/openapi/local-api.json +++ b/internal/server/static/openapi/local-api.json @@ -4027,6 +4027,39 @@ } } }, + "/api/cloud/auth/callback": { + "get": { + "tags": [ + "CloudAuth" + ], + "summary": "Complete the OpenCSG SSO login redirect", + "description": "Browser redirect target for the lite SSO flow. Persists the access token carried in the token query parameter and returns a confirmation page.", + "operationId": "completeCloudAuthCallback", + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "description": "OpenCSG access token issued to the lite client.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTML confirmation showing whether the token was saved.", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/api/cloud/auth/token": { "post": { "tags": [ diff --git a/openapi/local-api.json b/openapi/local-api.json index 511fed5..fc3b696 100644 --- a/openapi/local-api.json +++ b/openapi/local-api.json @@ -4027,6 +4027,39 @@ } } }, + "/api/cloud/auth/callback": { + "get": { + "tags": [ + "CloudAuth" + ], + "summary": "Complete the OpenCSG SSO login redirect", + "description": "Browser redirect target for the lite SSO flow. Persists the access token carried in the token query parameter and returns a confirmation page.", + "operationId": "completeCloudAuthCallback", + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "description": "OpenCSG access token issued to the lite client.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTML confirmation showing whether the token was saved.", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/api/cloud/auth/token": { "post": { "tags": [ diff --git a/web/src/i18n.ts b/web/src/i18n.ts index 7f4e54b..5527417 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -1111,7 +1111,6 @@ const en: Record = { "settings.loggedOut": "Not logged in", "settings.loggedOutDesc": "Sign in to OpenCSG and save an Access Token to use the built-in API Key for OpenCSG models.", "settings.login": "Open Login", - "settings.openTokenPage": "Open Access Token Page", "settings.tokenSaved": "Access Token saved", "settings.tokenSavedDesc": "We couldn't load profile details right now. You can sign in again or log out.", "settings.tokenInputHint": "Paste an OpenCSG Access Token here to sign in or replace the saved token.", @@ -1572,12 +1571,6 @@ const en: Record = { "chat.cloudLoginTitle": "Login required for {0} models", "chat.cloudLoginDesc": "{0} models use the configured AI Gateway. Sign in with an Access Token; csghub-lite will load your built-in API Key for model requests.", "chat.cloudOpenLogin": "Open Login", - "chat.cloudOpenTokenPage": "Open Access Token Page", - "chat.cloudTokenLabel": "Access Token", - "chat.cloudTokenPlaceholder": "Paste your OpenCSG access token", - "chat.cloudTokenHint": "Create or copy an Access Token from your OpenCSG account settings, then paste it here.", - "chat.cloudSaveToken": "Save Token", - "chat.cloudSavingToken": "Saving...", "chat.cloudCancel": "Not now", "chat.cloudApiKeyTitle": "OpenCSG AI Gateway API Key", "chat.cloudApiKeyDesc": "OpenCSG models use the OpenCSG AI Gateway. Enter your API Key here to enable cloud model requests from csghub-lite.", @@ -1595,8 +1588,6 @@ const en: Record = { "chat.cloudLoginRequired": "Cloud models require a cloud login.", "chat.cloudAuthRequired": "Cloud login required. Please sign in to {0} or save an API Key.", "chat.cloudBuiltinAPIKeyFailed": "Failed to load the {0} built-in API Key. Please sign in again or save an API Key.", - "chat.cloudLoginExpired": "Cloud login expired or token is invalid. Please log in again and update your Access Token.", - "chat.cloudTokenEmpty": "Please paste an Access Token.", "chat.copyModel": "Copy model name", "chat.conversations": "Conversations", "chat.conversationHistory": "Conversation History", @@ -2791,7 +2782,6 @@ const zh: Record = { "settings.loggedOut": "未登录", "settings.loggedOutDesc": "登录 OpenCSG 并保存 Access Token 后,可使用账号的 built-in API Key 调用 OpenCSG 模型。", "settings.login": "打开登录", - "settings.openTokenPage": "打开 Access Token 页面", "settings.tokenSaved": "已保存 Access Token", "settings.tokenSavedDesc": "暂时无法加载账号详情。你可以重新登录或退出。", "settings.tokenInputHint": "在这里粘贴 OpenCSG Access Token,用于登录或替换已保存的 Token。", @@ -3252,12 +3242,6 @@ const zh: Record = { "chat.cloudLoginTitle": "使用 {0} 模型需要登录", "chat.cloudLoginDesc": "{0} 模型通过当前配置的 AI Gateway 调用。请用 Access Token 登录,csghub-lite 会在调用模型时获取你的 built-in API Key。", "chat.cloudOpenLogin": "打开登录", - "chat.cloudOpenTokenPage": "打开 Access Token 页面", - "chat.cloudTokenLabel": "Access Token", - "chat.cloudTokenPlaceholder": "请粘贴你的 OpenCSG Access Token", - "chat.cloudTokenHint": "可在 OpenCSG 账号设置中创建或复制 Access Token,然后粘贴到这里。", - "chat.cloudSaveToken": "保存 Token", - "chat.cloudSavingToken": "保存中...", "chat.cloudCancel": "稍后再说", "chat.cloudApiKeyTitle": "OpenCSG AI Gateway API Key", "chat.cloudApiKeyDesc": "OpenCSG 模型通过 OpenCSG AI Gateway 调用。请在这里填写 API Key,csghub-lite 会用它发起云端模型请求。", @@ -3275,8 +3259,6 @@ const zh: Record = { "chat.cloudLoginRequired": "使用云端模型需要先登录云端服务。", "chat.cloudAuthRequired": "需要登录云端服务。请登录 {0} 或保存 API Key。", "chat.cloudBuiltinAPIKeyFailed": "无法加载 {0} built-in API Key。请重新登录或保存 API Key。", - "chat.cloudLoginExpired": "云端登录已过期或 Token 无效,请重新登录并更新 Access Token。", - "chat.cloudTokenEmpty": "请先粘贴 Access Token。", "chat.conversations": "对话记录", "chat.conversationHistory": "对话记录", "chat.noConversations": "暂无对话记录", diff --git a/web/src/pages/AIApps.tsx b/web/src/pages/AIApps.tsx index 2c53fda..efbf9ba 100644 --- a/web/src/pages/AIApps.tsx +++ b/web/src/pages/AIApps.tsx @@ -8,7 +8,6 @@ import { installAIApp, openAIApp, saveAIAppModel, - saveCloudToken, setAIAppPath, startAIApp, stopAIApp, @@ -646,8 +645,6 @@ function LiveLogsDrawer({ const [cloudAuth, setCloudAuth] = useState(null); const [showCloudAuthDialog, setShowCloudAuthDialog] = useState(false); const [cloudAuthError, setCloudAuthError] = useState(""); - const [cloudTokenInput, setCloudTokenInput] = useState(""); - const [isSavingCloudToken, setIsSavingCloudToken] = useState(false); const [manualPathInput, setManualPathInput] = useState(""); const [isSavingManualPath, setIsSavingManualPath] = useState(false); const [manualPathNotice, setManualPathNotice] = useState<{ ok: boolean; text: string } | null>(null); @@ -922,6 +919,34 @@ function LiveLogsDrawer({ } }; + const pollCloudAuthAfterLogin = () => { + const deadline = Date.now() + 5 * 60 * 1000; + let timer: number | undefined; + + const poll = async () => { + try { + const status = await getCloudAuthStatus(); + setCloudAuth(status); + if (status.authenticated && status.user) { + setCloudAuthError(""); + setShowCloudAuthDialog(false); + loadModelOptions({ refresh: true }).then(setModels).catch(() => {}); + return; + } + } catch { + /* keep polling until the deadline */ + } + if (Date.now() < deadline) { + timer = window.setTimeout(poll, 1500); + } + }; + + void poll(); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + }; + }; + const ensureCloudAuthForModel = async (model: ModelInfo | undefined): Promise => { if (model?.source !== "cloud") { return true; @@ -1057,36 +1082,6 @@ function LiveLogsDrawer({ onOpenChat(currentModelID || undefined, currentModelInfo?.source); }; - const handleSaveCloudToken = async () => { - const token = cloudTokenInput.trim(); - if (!token) { - setCloudAuthError(t("chat.cloudTokenEmpty")); - return; - } - - setIsSavingCloudToken(true); - setCloudAuthError(""); - try { - const status = await saveCloudToken(token); - setCloudAuth(status); - if (!status.authenticated) { - setCloudAuthError(t("chat.cloudLoginExpired")); - return; - } - try { - setModels(await loadModelOptions({ refresh: true })); - } catch { - /* ignore */ - } - setCloudTokenInput(""); - setShowCloudAuthDialog(false); - } catch (error) { - setCloudAuthError((error as Error).message || t("chat.failedResp")); - } finally { - setIsSavingCloudToken(false); - } - }; - return (
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}> @@ -1678,31 +1673,14 @@ function LiveLogsDrawer({
- -
- -
- - setCloudTokenInput((e.currentTarget as HTMLInputElement).value)} - /> -

{t("chat.cloudTokenHint")}

@@ -1715,13 +1693,6 @@ function LiveLogsDrawer({ > {t("chat.cloudCancel")} -
diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index e3fa1f2..cce23cb 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -2,7 +2,7 @@ import MarkdownIt from "markdown-it"; import { useEffect, useRef, useState } from "preact/hooks"; import { signal, computed } from "@preact/signals"; import { - getPs, streamChat, getCloudAuthStatus, saveCloudToken, + getPs, streamChat, getCloudAuthStatus, listConversations, searchConversations, getConversation, createConversation, updateConversation, deleteConversation, getSettings, createImageGenerationJob, getImageGenerationJob, cancelImageGenerationJob, getASRRuntimeStatus, transcribeAudioStream, } from "../api/client"; @@ -33,9 +33,7 @@ const showSidebar = signal(true); const showCloudAuthDialog = signal(false); const openConversationMenuId = signal(""); const cloudAuth = signal(null); -const cloudTokenInput = signal(""); const cloudAuthError = signal(""); -const isSavingCloudToken = signal(false); const webSearchEnabled = signal(true); const webSearchAvailable = signal(false); const streamingSources = signal([]); @@ -51,6 +49,31 @@ function openExternalURL(url?: string) { window.open(url, "_blank", "noopener,noreferrer"); } +function pollCloudAuthAfterLogin(applyStatus: (status: CloudAuthStatus) => void) { + const deadline = Date.now() + 5 * 60 * 1000; + let timer: number | undefined; + + const poll = async () => { + try { + const status = await getCloudAuthStatus(); + applyStatus(status); + if (status.authenticated && status.user) { + return; + } + } catch { + /* keep polling until the deadline */ + } + if (Date.now() < deadline) { + timer = window.setTimeout(poll, 1500); + } + }; + + void poll(); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + }; +} + const systemPrompt = signal(""); const defaultChatTemperature = 0.95; const kimiChatTemperature = 0.6; @@ -873,36 +896,6 @@ export function Chat() { } }; - const handleSaveCloudToken = async () => { - const token = cloudTokenInput.value.trim(); - if (!token) { - cloudAuthError.value = t("chat.cloudTokenEmpty"); - return; - } - - isSavingCloudToken.value = true; - cloudAuthError.value = ""; - try { - const status = await saveCloudToken(token); - cloudAuth.value = status; - if (!status.authenticated) { - cloudAuthError.value = t("chat.cloudLoginExpired", configuredCloudProviderName()); - return; - } - try { - setAvailableModels(await loadModelOptions({ refresh: true })); - } catch { - /* ignore */ - } - cloudTokenInput.value = ""; - showCloudAuthDialog.value = false; - } catch (e: any) { - cloudAuthError.value = e?.message || t("chat.failedResp"); - } finally { - isSavingCloudToken.value = false; - } - }; - useEffect(() => { const refreshModels = () => { loadModelOptions({ refresh: true }).then((m) => { @@ -2129,31 +2122,22 @@ export function Chat() {
- -
- -
- - (cloudTokenInput.value = (e.target as HTMLInputElement).value)} - /> -

{t("chat.cloudTokenHint")}

@@ -2166,13 +2150,6 @@ export function Chat() { > {t("chat.cloudCancel")} -
diff --git a/web/src/pages/ImageGeneration.tsx b/web/src/pages/ImageGeneration.tsx index b78f644..f1afc30 100644 --- a/web/src/pages/ImageGeneration.tsx +++ b/web/src/pages/ImageGeneration.tsx @@ -2,14 +2,12 @@ import { useEffect } from "preact/hooks"; import { signal } from "@preact/signals"; import { cancelImageGenerationJob, - clearCloudAPIKey, createImageGenerationJob, getImageGenerationJob, getImageRuntimeStatus, getCloudAuthStatus, installImageRuntime, listImageGenerationJobs, - saveCloudToken, } from "../api/client"; import type { CloudAuthStatus, ImageGenerationJobResponse, ImageRuntimeStatus, ModelInfo } from "../api/client"; import { ApiInfoDialog } from "../components/ApiInfoDialog"; @@ -65,8 +63,6 @@ const cloudAuthDialogOpen = signal(false); const cloudAuth = signal(null); const cloudAuthLoaded = signal(false); const cloudAuthError = signal(""); -const cloudTokenInput = signal(""); -const isSavingCloudToken = signal(false); const providersChangedEvent = "csghub:providers-changed"; interface GenerationHistoryItem { @@ -446,6 +442,35 @@ async function openCloudAuthDialog(message = "") { } } +function pollCloudAuthAfterLogin() { + const deadline = Date.now() + 5 * 60 * 1000; + let timer: number | undefined; + + const poll = async () => { + try { + const status = await getCloudAuthStatus(); + cloudAuth.value = status; + if (status.authenticated && status.user) { + cloudAuthError.value = ""; + cloudAuthDialogOpen.value = false; + error.value = ""; + await refreshImageModels(); + return; + } + } catch { + /* keep polling until the deadline */ + } + if (Date.now() < deadline) { + timer = window.setTimeout(poll, 1500); + } + }; + + void poll(); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + }; +} + export function ImageGeneration() { void locale.value; @@ -614,36 +639,6 @@ export function ImageGeneration() { generationStartedAt.value = 0; }; - const handleSaveCloudToken = async () => { - const token = cloudTokenInput.value.trim(); - if (!token) { - cloudAuthError.value = t("chat.cloudTokenEmpty"); - return; - } - isSavingCloudToken.value = true; - cloudAuthError.value = ""; - try { - const status = await saveCloudToken(token); - cloudAuth.value = status; - if (!status.authenticated) { - cloudAuthError.value = t("chat.cloudLoginExpired", currentModel?.provider || t("chat.cloud")); - return; - } - // The image dialog saves account tokens; clear any stale manual API key - // that may have been saved by older builds so cloud calls use the - // account's built-in API key. - cloudAuth.value = await clearCloudAPIKey(); - cloudTokenInput.value = ""; - cloudAuthDialogOpen.value = false; - error.value = ""; - await refreshImageModels(); - } catch (err: any) { - cloudAuthError.value = err?.message || t("chat.failedResp"); - } finally { - isSavingCloudToken.value = false; - } - }; - const rt = runtime.value; const currentModel = selectedModelInfo(); const selectedModelIsLocal = isLocalImageModel(currentModel); @@ -1110,32 +1105,14 @@ export function ImageGeneration() {
- -
- -
- - (cloudTokenInput.value = (e.target as HTMLInputElement).value)} - /> -

{t("chat.cloudTokenHint")}

@@ -1149,14 +1126,6 @@ export function ImageGeneration() { > {t("chat.cloudCancel")} -
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index d0d3477..48c51ba 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -13,7 +13,6 @@ import { getTags, installImageRuntime, getSettings, - saveCloudToken, saveSettings, upgradeWithProgress, } from "../api/client"; @@ -41,10 +40,8 @@ const contextIndex = signal(1); const contextMode = signal("global"); const parallelIndex = signal(2); const cloudAuth = signal(null); -const cloudTokenInput = signal(""); const cloudAuthError = signal(""); const isClearingCloudToken = signal(false); -const isSavingCloudToken = signal(false); const isSavingStorageDir = signal(false); const storageDirInput = signal(""); const storageDirError = signal(""); @@ -300,6 +297,32 @@ function fetchCloudAuth() { }); } +function pollCloudAuthAfterLogin() { + const deadline = Date.now() + 5 * 60 * 1000; + let timer: number | undefined; + + const poll = async () => { + try { + const status = await getCloudAuthStatus(); + cloudAuth.value = status; + if (status.authenticated && status.user) { + cloudAuthError.value = ""; + return; + } + } catch (err: any) { + cloudAuthError.value = err?.message || ""; + } + if (Date.now() < deadline) { + timer = window.setTimeout(poll, 1500); + } + }; + + void poll(); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + }; +} + async function fetchUpgradeInfo() { try { const upgrade = await checkUpgrade(); @@ -692,10 +715,7 @@ function RegistryCredentialFields({ function openCloudLogin() { openExternal(cloudAuth.value?.login_url); -} - -function openCloudTokenPage() { - openExternal(cloudAuth.value?.access_token_url); + pollCloudAuthAfterLogin(); } async function logoutCloudAccount() { @@ -711,33 +731,7 @@ async function logoutCloudAccount() { } } -async function saveOpenCSGToken() { - const token = cloudTokenInput.value.trim(); - if (!token) { - cloudAuthError.value = t("chat.cloudTokenEmpty"); - return; - } - - isSavingCloudToken.value = true; - cloudAuthError.value = ""; - try { - const status = await saveCloudToken(token); - cloudAuth.value = status; - if (!status.authenticated) { - cloudAuthError.value = t("chat.cloudLoginExpired"); - return; - } - cloudTokenInput.value = ""; - } catch (err: any) { - cloudAuthError.value = err?.message || t("chat.failedResp"); - } finally { - isSavingCloudToken.value = false; - } -} - function OpenCSGAccountPanel() { - const showTokenInput = !(cloudAuth.value?.authenticated && cloudAuth.value?.user); - return (
@@ -774,13 +768,6 @@ function OpenCSGAccountPanel() {
- - -
- - )} - {showTokenInput && ( -
- -

{t("settings.tokenInputHint")}

-
- (cloudTokenInput.value = (e.target as HTMLInputElement).value)} - /> -
)}