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 completeLogin complete. You can close this window and return to csglite.
`)
+ return
+ }
+ _, _ = io.WriteString(w, `Login failedLogin 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)}
- />
-
-
)}