From e5c3a1228303646ce64fe90f5e02fcd51deb2605 Mon Sep 17 00:00:00 2001 From: ganisback <370036720@qq.com> Date: Thu, 3 Sep 2026 19:59:08 +0800 Subject: [PATCH 1/2] feat(cloud): complete OpenCSG SSO login automatically on the lite side Add an SSO callback flow so that after a user clicks "Sign in" in the csglite settings page, the OpenCSG web login can hand the access token straight back to the lite process without any further manual step. - change the login authorization state from casdoor to lite - start a dedicated loopback callback listener on 127.0.0.1:11437 - add GET /api/cloud/auth/callback to persist the returned token and render a small confirmation page - poll the cloud auth status in the web UI after opening the login page - document the new endpoint in the OpenAPI spec and its static copy Closes https://github.com/OpenCSGs/csglite/issues/135 Depends on https://git-devops.opencsg.com/product/starhub/starhub-server/-/merge_requests/2977 --- internal/cloud/opencsg.go | 2 +- internal/config/config.go | 2 + internal/server/handlers_cloud.go | 46 ++++++++++++++++-- internal/server/handlers_cloud_test.go | 43 +++++++++++++++++ internal/server/routes.go | 6 +++ internal/server/server.go | 47 +++++++++++++++++-- internal/server/static/openapi/local-api.json | 33 +++++++++++++ openapi/local-api.json | 33 +++++++++++++ web/src/pages/Settings.tsx | 27 +++++++++++ 9 files changed, 230 insertions(+), 9 deletions(-) diff --git a/internal/cloud/opencsg.go b/internal/cloud/opencsg.go index 92ea725..82cddac 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 3cccd76..b281ef0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,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" @@ -85,6 +86,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 152429d..9a7791d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -204,6 +204,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 3df2fd4..d922b9e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -158,10 +158,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 @@ -302,6 +303,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 @@ -338,10 +349,23 @@ 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) - errCh := make(chan error, 2) + errCh := make(chan error, 3) if s.cfg.DesktopMode { baseURL := "http://" + boundAddr ready := desktopReady{ @@ -380,6 +404,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: @@ -416,6 +448,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 2afc24d..6924aab 100644 --- a/internal/server/static/openapi/local-api.json +++ b/internal/server/static/openapi/local-api.json @@ -3833,6 +3833,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 2afc24d..6924aab 100644 --- a/openapi/local-api.json +++ b/openapi/local-api.json @@ -3833,6 +3833,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/pages/Settings.tsx b/web/src/pages/Settings.tsx index 38ee7c3..bbb8e91 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -297,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(); @@ -656,6 +682,7 @@ export function Settings() { const handleOpenCloudLogin = () => { openExternal(cloudAuth.value?.login_url); + pollCloudAuthAfterLogin(); }; const handleOpenCloudTokenPage = () => { From 1654ef23cf9910b66fb9141d8b1f6a6f21c69efb Mon Sep 17 00:00:00 2001 From: ganisback <370036720@qq.com> Date: Sat, 5 Sep 2026 10:38:56 +0800 Subject: [PATCH 2/2] feat(cloud): finish lite-side SSO auto login in web dialogs Replace the manual Access Token entry flow in the Chat, AI Apps, Image Generation, and Settings dialogs with polling: clicking Open Login opens SSO and polls cloud auth status until the callback persists the token, then refreshes the model lists. Removes the now-unused token input UI and i18n strings. --- web/src/i18n.ts | 18 ------ web/src/pages/AIApps.tsx | 93 ++++++++++------------------- web/src/pages/Chat.tsx | 99 ++++++++++++------------------- web/src/pages/ImageGeneration.tsx | 97 +++++++++++------------------- web/src/pages/Settings.tsx | 70 ---------------------- 5 files changed, 103 insertions(+), 274 deletions(-) diff --git a/web/src/i18n.ts b/web/src/i18n.ts index a314ae1..119bc0b 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -875,7 +875,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.", @@ -1328,12 +1327,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.", @@ -1351,8 +1344,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", @@ -2311,7 +2302,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。", @@ -2764,12 +2754,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 会用它发起云端模型请求。", @@ -2787,8 +2771,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 4b67db4..143f63c 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, @@ -630,8 +629,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); @@ -906,6 +903,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; @@ -1041,36 +1066,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()}> @@ -1662,31 +1657,14 @@ function LiveLogsDrawer({
- -
- -
- - setCloudTokenInput((e.currentTarget as HTMLInputElement).value)} - /> -

{t("chat.cloudTokenHint")}

@@ -1699,13 +1677,6 @@ function LiveLogsDrawer({ > {t("chat.cloudCancel")} -
diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 013ae04..ff82865 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"; @@ -34,9 +34,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([]); @@ -52,6 +50,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; @@ -895,36 +918,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) => { @@ -2182,31 +2175,22 @@ export function Chat() {
- -
- -
- - (cloudTokenInput.value = (e.target as HTMLInputElement).value)} - /> -

{t("chat.cloudTokenHint")}

@@ -2219,13 +2203,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 bbb8e91..bbf9394 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(""); @@ -669,7 +666,6 @@ function RegistryCredentialFields({ export function Settings() { void locale.value; - const showTokenInput = !(cloudAuth.value?.authenticated && cloudAuth.value?.user); useEffect(() => { fetchSettings(); @@ -685,10 +681,6 @@ export function Settings() { pollCloudAuthAfterLogin(); }; - const handleOpenCloudTokenPage = () => { - openExternal(cloudAuth.value?.access_token_url); - }; - const handleLogout = async () => { if (isClearingCloudToken.value) return; isClearingCloudToken.value = true; @@ -702,30 +694,6 @@ export function Settings() { } }; - 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"); - return; - } - cloudTokenInput.value = ""; - } catch (err: any) { - cloudAuthError.value = err?.message || t("chat.failedResp"); - } finally { - isSavingCloudToken.value = false; - } - }; - return (

{t("settings.title")}

@@ -1201,12 +1169,6 @@ export function Settings() {
- - -
- - )} - {showTokenInput && ( -
- -

{t("settings.tokenInputHint")}

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