diff --git a/api/apps/restful_apis/aimlapi_api.py b/api/apps/restful_apis/aimlapi_api.py new file mode 100644 index 0000000000..c1874bab19 --- /dev/null +++ b/api/apps/restful_apis/aimlapi_api.py @@ -0,0 +1,194 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""AIMLAPI agent-authorization endpoints (OAuth 2.0 Device Authorization Grant). + +Lets a signed-in RAGFlow user obtain an AIMLAPI key without leaving the provider +dialog. The user's AIMLAPI login credentials never touch RAGFlow: it creates a +device-authorization request, the user approves it on the AIMLAPI consent page +(opened by the browser in a popup), and RAGFlow polls the token endpoint until +the key is issued. The device code is held server-side (Redis); only the issued +API key is returned to the browser, to be filled into the provider form. + +Configuration (all via environment): + AIMLAPI_APP_URL agent-auth host (default https://app.aimlapi.com) + AIMLAPI_PARTNER_ID partner id (defaults to RAGFlow's; override to change) + AIMLAPI_PARTNER_NAME display name shown on the consent page + AIMLAPI_VERIFICATION_BASE_URL base of the consent page (default https://aimlapi.com) + AIMLAPI_REQUESTED_USD_LIMIT_MINOR requested spend cap in USD minor units (default 1000) +""" + +import asyncio +import json +import logging +import os + +import requests + +from api.apps import login_required, current_user +from api.utils.api_utils import get_data_error_result, get_json_result, get_request_json, server_error_response, validate_request +from rag.utils.redis_conn import REDIS_CONN + +LOGGER = logging.getLogger(__name__) + +# OAuth 2.0 Device Authorization Grant (RFC 8628) +_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" +_REDIS_KEY_PREFIX = "aimlapi_authz:" +_HTTP_TIMEOUT = 15 + + +def _app_url() -> str: + return os.environ.get("AIMLAPI_APP_URL", "https://app.aimlapi.com").rstrip("/") + + +def _partner_id() -> str: + return os.environ.get("AIMLAPI_PARTNER_ID", "part_yNkcOvbGLtgxWLjy4sRysaer").strip() + + +def _partner_name() -> str: + return os.environ.get("AIMLAPI_PARTNER_NAME", "RAGFlow").strip() + + +def _verification_base_url() -> str: + return os.environ.get("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com").rstrip("/") + + +def _requested_usd_limit_minor() -> int: + try: + return int(os.environ.get("AIMLAPI_REQUESTED_USD_LIMIT_MINOR", "1000")) + except (TypeError, ValueError): + return 1000 + + +@manager.route("/llm/aimlapi/authorize/start", methods=["POST"]) # noqa: F821 +@login_required +async def aimlapi_authorize_start(): + """Create an AIMLAPI device-authorization request and return the consent URL.""" + partner_id = _partner_id() + if not partner_id: + return get_data_error_result(message="AIMLAPI partner id is not configured. Set AIMLAPI_PARTNER_ID.") + + # returnUrl only controls where AIMLAPI sends the browser after consent. This + # flow never depends on it — the key arrives via authenticated polling and the + # popup is closed on success — so we always use the trusted verification host + # rather than a client-supplied value, leaving no open-redirect surface. + return_url = _verification_base_url() + + payload = { + "partnerId": partner_id, + "partnerName": _partner_name(), + "agentName": "RAGFlow", + "returnUrl": return_url, + "requestedUsdLimitMinor": _requested_usd_limit_minor(), + } + try: + resp = await asyncio.to_thread( + requests.post, + f"{_app_url()}/v3/agent-auth/authorizations", + json=payload, + timeout=_HTTP_TIMEOUT, + ) + except Exception as e: + return server_error_response(e) + + if resp.status_code not in (200, 201): + LOGGER.warning("AIMLAPI authorize start failed: status=%s body=%s", resp.status_code, resp.text[:300]) + return get_data_error_result(message=f"AIMLAPI authorization request failed (HTTP {resp.status_code}).") + + data = resp.json() + request_id = data.get("requestId") + device_code = data.get("deviceCode") + if not request_id or not device_code: + return get_data_error_result(message="AIMLAPI authorization response is missing requestId/deviceCode.") + + try: + interval = int(data.get("interval", 5)) + except (TypeError, ValueError): + interval = 5 + try: + expires_in = int(data.get("expiresIn", 900)) + except (TypeError, ValueError): + expires_in = 900 + + # Hold the device code server-side, scoped to this user; it never reaches the browser. + REDIS_CONN.set_obj( + f"{_REDIS_KEY_PREFIX}{request_id}", + {"device_code": device_code, "user_id": current_user.id}, + expires_in, + ) + LOGGER.info("AIMLAPI authorize start ok: request_id=%s", request_id) + + # Rebuild the consent URL from AIMLAPI_VERIFICATION_BASE_URL so an env override applies; the create response returns an absolute URL. + verification_uri = f"{_verification_base_url()}/agent/authorize?request={request_id}" + return get_json_result( + data={ + "request_id": request_id, + "verification_uri": verification_uri, + "interval": interval, + "expires_in": expires_in, + } + ) + + +@manager.route("/llm/aimlapi/authorize/poll", methods=["POST"]) # noqa: F821 +@login_required +@validate_request("request_id") +async def aimlapi_authorize_poll(): + """Poll the AIMLAPI token endpoint; return the issued key once the user approves.""" + req = await get_request_json() + request_id = req["request_id"] + + raw = REDIS_CONN.get(f"{_REDIS_KEY_PREFIX}{request_id}") + if not raw: + return get_json_result(data={"status": "expired"}) + try: + cached = json.loads(raw) + except (TypeError, ValueError): + cached = None + if not cached or cached.get("user_id") != current_user.id: + return get_data_error_result(message="Authorization request not found for the current user.") + + payload = { + "partnerId": _partner_id(), + "deviceCode": cached["device_code"], + "grant_type": _DEVICE_CODE_GRANT, + } + try: + resp = await asyncio.to_thread( + requests.post, + f"{_app_url()}/v3/agent-auth/token", + json=payload, + timeout=_HTTP_TIMEOUT, + ) + except Exception as e: + return server_error_response(e) + + if resp.status_code not in (200, 201): + LOGGER.warning("AIMLAPI authorize poll failed: status=%s body=%s", resp.status_code, resp.text[:300]) + return get_data_error_result(message=f"AIMLAPI token poll failed (HTTP {resp.status_code}).") + + data = resp.json() + status = str(data.get("status", "")).lower() + # The success field name is not contractually fixed yet; accept the common variants. + api_key = data.get("apiKey") or data.get("api_key") or data.get("access_token") or data.get("key") + + if api_key: + REDIS_CONN.delete(f"{_REDIS_KEY_PREFIX}{request_id}") + LOGGER.info("AIMLAPI authorize poll ready: request_id=%s", request_id) + return get_json_result(data={"status": "ready", "api_key": api_key}) + if status in ("denied", "expired", "cancelled", "canceled", "rejected"): + REDIS_CONN.delete(f"{_REDIS_KEY_PREFIX}{request_id}") + return get_json_result(data={"status": status}) + return get_json_result(data={"status": status or "pending"}) diff --git a/conf/llm_factories.json b/conf/llm_factories.json index e3436ee11b..36dea54024 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -1,5 +1,84 @@ { "factory_llm_infos": [ + { + "name": "aimlapi.com", + "logo": "", + "tags": "LLM,TEXT EMBEDDING,IMAGE2TEXT", + "status": "1", + "rank": "1", + "url": "https://api.aimlapi.com/v1", + "llm": [ + { + "llm_name": "openai/gpt-4o", + "tags": "LLM,CHAT,128k,IMAGE2TEXT", + "max_tokens": 128000, + "model_type": [ + "chat", + "image2text" + ], + "is_tools": true + }, + { + "llm_name": "openai/gpt-4o-mini", + "tags": "LLM,CHAT,128k,IMAGE2TEXT", + "max_tokens": 128000, + "model_type": [ + "chat", + "image2text" + ], + "is_tools": true + }, + { + "llm_name": "anthropic/claude-opus-4-8", + "tags": "LLM,CHAT,200k", + "max_tokens": 200000, + "model_type": "chat", + "is_tools": true + }, + { + "llm_name": "anthropic/claude-haiku-4.5", + "tags": "LLM,CHAT,200k", + "max_tokens": 200000, + "model_type": "chat", + "is_tools": true + }, + { + "llm_name": "google/gemini-2.5-pro", + "tags": "LLM,CHAT,1M", + "max_tokens": 1000000, + "model_type": "chat", + "is_tools": true + }, + { + "llm_name": "google/gemini-2.5-flash", + "tags": "LLM,CHAT,1M", + "max_tokens": 1000000, + "model_type": "chat", + "is_tools": true + }, + { + "llm_name": "deepseek/deepseek-chat", + "tags": "LLM,CHAT,64k", + "max_tokens": 65536, + "model_type": "chat", + "is_tools": true + }, + { + "llm_name": "openai/text-embedding-3-small", + "tags": "TEXT EMBEDDING,8K", + "max_tokens": 8191, + "model_type": "embedding", + "is_tools": false + }, + { + "llm_name": "openai/text-embedding-3-large", + "tags": "TEXT EMBEDDING,8K", + "max_tokens": 8191, + "model_type": "embedding", + "is_tools": false + } + ] + }, { "name": "OpenAI", "logo": "", diff --git a/docs/guides/models/supported_models.mdx b/docs/guides/models/supported_models.mdx index fa4b7a355a..7dbc8cc2eb 100644 --- a/docs/guides/models/supported_models.mdx +++ b/docs/guides/models/supported_models.mdx @@ -17,6 +17,7 @@ A complete list of model providers supported by RAGFlow, which will continue to | Provider | URL | | --------------------- | ----------------------------------------------- | +| aimlapi.com | `https://aimlapi.com` | | Anthropic | `https://www.anthropic.com` | | Astraflow | `https://astraflow.ucloud-global.com/en-us` | | Astraflow-CN | `https://astraflow.ucloud.cn/` | diff --git a/rag/llm/__init__.py b/rag/llm/__init__.py index 7a612380f9..d0347e519f 100644 --- a/rag/llm/__init__.py +++ b/rag/llm/__init__.py @@ -62,6 +62,7 @@ class SupportedLiteLLMProvider(StrEnum): Astraflow = "Astraflow" Astraflow_CN = "Astraflow-CN" FuturMix = "FuturMix" + AIMLAPI = "aimlapi.com" FACTORY_DEFAULT_BASE_URL = { @@ -94,6 +95,7 @@ FACTORY_DEFAULT_BASE_URL = { SupportedLiteLLMProvider.Astraflow: "https://api-us-ca.umodelverse.ai/v1", SupportedLiteLLMProvider.Astraflow_CN: "https://api.modelverse.cn/v1", SupportedLiteLLMProvider.FuturMix: "https://futurmix.ai/v1", + SupportedLiteLLMProvider.AIMLAPI: "https://api.aimlapi.com/v1", } @@ -137,6 +139,7 @@ LITELLM_PROVIDER_PREFIX = { SupportedLiteLLMProvider.Astraflow: "openai/", SupportedLiteLLMProvider.Astraflow_CN: "openai/", SupportedLiteLLMProvider.FuturMix: "openai/", + SupportedLiteLLMProvider.AIMLAPI: "openai/", } ChatModel = globals().get("ChatModel", {}) diff --git a/rag/llm/chat_model.py b/rag/llm/chat_model.py index 59a792c84a..8a75135725 100644 --- a/rag/llm/chat_model.py +++ b/rag/llm/chat_model.py @@ -1554,6 +1554,15 @@ class FuturMixChat(Base): logging.info("[FuturMix] Chat initialized with model %s", model_name) +class AIMLAPIChat(Base): + _FACTORY_NAME = "aimlapi.com" + + def __init__(self, key, model_name, base_url="", **kwargs): + base_url = base_url or os.environ.get("AIMLAPI_API_URL", "https://api.aimlapi.com/v1") + super().__init__(key, model_name, base_url, **kwargs) + logging.info("[aimlapi.com] Chat initialized with model %s", model_name) + + class LiteLLMBase(ABC): _FACTORY_NAME = [ "Tongyi-Qianwen", diff --git a/rag/llm/cv_model.py b/rag/llm/cv_model.py index 603601c4b3..14ef9557b1 100644 --- a/rag/llm/cv_model.py +++ b/rag/llm/cv_model.py @@ -1398,6 +1398,15 @@ class FuturMixCV(GptV4): logging.info("[FuturMix] CV initialized with model %s", model_name) +class AIMLAPICV(GptV4): + _FACTORY_NAME = "aimlapi.com" + + def __init__(self, key, model_name, lang="Chinese", base_url="", **kwargs): + base_url = base_url or os.environ.get("AIMLAPI_API_URL", "https://api.aimlapi.com/v1") + super().__init__(key, model_name, lang=lang, base_url=base_url, **kwargs) + logging.info("[aimlapi.com] CV initialized with model %s", model_name) + + class RAGconCV(GptV4): """ RAGcon CV Provider - routes through LiteLLM proxy diff --git a/rag/llm/embedding_model.py b/rag/llm/embedding_model.py index 3d246e1b8e..ede09d28b9 100644 --- a/rag/llm/embedding_model.py +++ b/rag/llm/embedding_model.py @@ -356,6 +356,15 @@ class FuturMixEmbed(OpenAIEmbed): logging.info("[FuturMix] Embedding initialized with model %s", model_name) +class AIMLAPIEmbed(OpenAIEmbed): + _FACTORY_NAME = "aimlapi.com" + + def __init__(self, key, model_name="text-embedding-3-small", base_url=""): + base_url = base_url or os.environ.get("AIMLAPI_API_URL", "https://api.aimlapi.com/v1") + super().__init__(key, model_name, base_url) + logging.info("[aimlapi.com] Embedding initialized with model %s", model_name) + + class BaiChuanEmbed(OpenAIEmbed): _FACTORY_NAME = "BaiChuan" diff --git a/rag/llm/model_meta.py b/rag/llm/model_meta.py index d5e34560dc..301cf8e119 100644 --- a/rag/llm/model_meta.py +++ b/rag/llm/model_meta.py @@ -504,3 +504,94 @@ class NewAPI(OpenAIAPICompatible): class RAGcon(OpenAIAPICompatible): _FACTORY_NAME = "RAGcon" + + +class AIMLAPI(Base): + """AIMLAPI (aimlapi.com) aggregates 700+ models behind an OpenAI-compatible + API. ``GET /v1/models`` returns one record per model *and* endpoint, so a + single model id repeats under different ``type`` values (e.g. + ``openai/chat-completions`` and ``openai/responses/submit``); records are + de-duplicated by id and their RAGFlow model types unioned. + + The ``type`` (endpoint family) field drives classification. Families RAGFlow + cannot consume — image/video/audio generation, batch, OCR — are intentionally + left out of the map, so those models are skipped. The listing carries no + modality flag, so image-capable chat models are detected from the id, the + same way OpenAIAPICompatible does. + """ + + _FACTORY_NAME = "aimlapi.com" + + _TYPE_TO_MODEL_TYPE = { + "openai/chat-completions": LLMType.CHAT.value, + "openai/responses/submit": LLMType.CHAT.value, + "anthropic/messages": LLMType.CHAT.value, + "openai/embeddings": LLMType.EMBEDDING.value, + "internal/text-to-speech": LLMType.TTS.value, + "internal/speech-to-text/submit": LLMType.ASR.value, + } + + # Chat models whose id hints at image input also serve VISION (VLM). + # Heuristic: the /v1/models listing exposes no structured modality field. + _VISION_HINTS = ( + "gpt-4o", + "gpt-4.1", + "gpt-4-turbo", + "gpt-5", + "chatgpt-4o", + "claude-3", + "claude-opus-4", + "claude-sonnet-4", + "claude-haiku-4", + "gemini", + "qwen-vl", + "qwen2-vl", + "qwen2.5-vl", + "qwen3-vl", + "internvl", + "llava", + "pixtral", + "minicpm-v", + "glm-4v", + "glm-4.1v", + "llama-3.2", + "llama-4", + "grok-2-vision", + "grok-4", + "vision", + "-vl", + ) + + def _format_model_list(self, raw_model_list): + models = raw_model_list.get("data") if isinstance(raw_model_list, dict) else raw_model_list + if not isinstance(models, list): + return [] + + merged = {} + for model in models: + if not isinstance(model, dict): + continue + model_id = model.get("id") + if not model_id: + continue + model_type = self._TYPE_TO_MODEL_TYPE.get(model.get("type")) + if not model_type: + continue + + entry = merged.get(model_id) + if entry is None: + info = model.get("info") or {} + entry = { + "name": model_id, + "model_types": [], + "features": [], + "max_tokens": info.get("contextLength") or 8192, + } + merged[model_id] = entry + + if model_type not in entry["model_types"]: + entry["model_types"].append(model_type) + if model_type == LLMType.CHAT.value and LLMType.VISION.value not in entry["model_types"] and any(hint in model_id.lower() for hint in self._VISION_HINTS): + entry["model_types"].append(LLMType.VISION.value) + + return list(merged.values()) diff --git a/web/src/assets/svg/llm/aimlapi.svg b/web/src/assets/svg/llm/aimlapi.svg new file mode 100644 index 0000000000..5b1efbb2db --- /dev/null +++ b/web/src/assets/svg/llm/aimlapi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index 261f789995..03b44ad1e6 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -108,6 +108,7 @@ const svgIcons = [ LLMFactory.Qiniu, LLMFactory.TokenHub, LLMFactory.FunASR, + LLMFactory.AIMLAPI, ]; export const LlmIcon = ({ diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index 758e8c770d..bf7045b5a6 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -87,6 +87,7 @@ export enum LLMFactory { TokenHub = 'TokenHub', NewAPI = 'New API', FunASR = 'FunASR', + AIMLAPI = 'aimlapi.com', } // Please lowercase the file name @@ -173,6 +174,7 @@ export const IconMap = { [LLMFactory.SoMark]: 'somark', [LLMFactory.NewAPI]: 'new-api', [LLMFactory.FunASR]: 'funasr', + [LLMFactory.AIMLAPI]: 'aimlapi', }; export const ModelTypeToField: Record = { @@ -195,6 +197,7 @@ export const FieldToModelType: Record = { export const APIMapUrl = { [LLMFactory.OpenAI]: 'https://platform.openai.com/api-keys', + [LLMFactory.AIMLAPI]: 'https://aimlapi.com/app/keys', [LLMFactory.Anthropic]: 'https://console.anthropic.com/settings/keys', [LLMFactory.Gemini]: 'https://aistudio.google.com/app/apikey', [LLMFactory.DeepSeek]: 'https://platform.deepseek.com/api_keys', diff --git a/web/src/hooks/use-aimlapi-authorize.ts b/web/src/hooks/use-aimlapi-authorize.ts new file mode 100644 index 0000000000..f526a10568 --- /dev/null +++ b/web/src/hooks/use-aimlapi-authorize.ts @@ -0,0 +1,186 @@ +import llmService from '@/services/llm-service'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Drives the AIMLAPI agent-authorization (OAuth 2.0 device grant) flow from the + * provider dialog: start → open the AIMLAPI consent page in a popup → poll the + * RAGFlow backend until the key is issued. RAGFlow never sees the user's AIMLAPI + * credentials; the popup handles sign-in/consent on aimlapi.com. + * + * Mirrors the data-source connector OAuth pattern + * (`data-source/component/google-drive-token-field.tsx`): a popup opened + * synchronously on click, a self-scheduling `setTimeout` poll loop, a manual + * "check status" fallback, and cleanup on unmount. + */ +export type AimlapiAuthorizeStatus = + | 'idle' + | 'starting' + | 'awaiting_consent' + | 'success' + | 'denied' + | 'expired' + | 'error'; + +const TERMINAL_DENIED = new Set(['denied', 'rejected']); +const TERMINAL_EXPIRED = new Set(['expired', 'cancelled', 'canceled']); +const POPUP_NAME = 'ragflow-aimlapi-oauth'; +const POPUP_FEATURES = 'width=600,height=760'; + +interface UseAimlapiAuthorizeOptions { + onKey: (apiKey: string) => void; +} + +export function useAimlapiAuthorize({ onKey }: UseAimlapiAuthorizeOptions) { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + + const pollTimerRef = useRef | null>(null); + const deadlineRef = useRef(0); + const requestIdRef = useRef(null); + const popupRef = useRef(null); + const stoppedRef = useRef(false); + const intervalMsRef = useRef(5000); + + const clearTimer = useCallback(() => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + stoppedRef.current = true; + clearTimer(); + }; + }, [clearTimer]); + + const poll = useCallback( + async (requestId: string, intervalMs: number) => { + if (stoppedRef.current) return; + if (Date.now() > deadlineRef.current) { + setStatus('expired'); + return; + } + try { + const { data } = await llmService.aimlapiAuthorizePoll({ + request_id: requestId, + }); + if (stoppedRef.current) return; + if (data?.code !== 0) { + setStatus('error'); + setError(data?.message ?? null); + return; + } + const result = data.data ?? {}; + const state: string = result.status ?? 'pending'; + if (state === 'ready' && result.api_key) { + setStatus('success'); + try { + popupRef.current?.close(); + } catch { + /* popup may be cross-origin/closed */ + } + onKey(result.api_key); + return; + } + if (TERMINAL_DENIED.has(state)) { + setStatus('denied'); + return; + } + if (TERMINAL_EXPIRED.has(state)) { + setStatus('expired'); + return; + } + // still pending → schedule the next poll + pollTimerRef.current = setTimeout( + () => poll(requestId, intervalMs), + intervalMs, + ); + } catch (e: any) { + if (stoppedRef.current) return; + setStatus('error'); + setError(e?.message ?? null); + } + }, + [onKey], + ); + + const start = useCallback(async () => { + stoppedRef.current = false; + setError(null); + setStatus('starting'); + clearTimer(); + + // Open the popup synchronously within the click handler so it is not blocked; + // navigate it once the backend returns the consent URL. + const popup = window.open('', POPUP_NAME, POPUP_FEATURES); + popupRef.current = popup; + + try { + const { data } = await llmService.aimlapiAuthorizeStart(); + if (data?.code !== 0) { + setStatus('error'); + setError(data?.message ?? null); + try { + popup?.close(); + } catch { + /* noop */ + } + return; + } + const { + request_id: requestId, + verification_uri: verificationUri, + interval, + expires_in: expiresIn, + } = data.data ?? {}; + + if (!requestId || !verificationUri) { + setStatus('error'); + setError('AIMLAPI authorization response is missing required fields.'); + try { + popup?.close(); + } catch { + /* noop */ + } + return; + } + + requestIdRef.current = requestId; + deadlineRef.current = Date.now() + (expiresIn ?? 900) * 1000; + + if (popup && verificationUri) { + popup.location.href = verificationUri; + } else if (verificationUri) { + window.open(verificationUri, POPUP_NAME, POPUP_FEATURES); + } + + setStatus('awaiting_consent'); + const intervalMs = Math.max(interval ?? 5, 1) * 1000; + intervalMsRef.current = intervalMs; + pollTimerRef.current = setTimeout( + () => poll(requestId, intervalMs), + intervalMs, + ); + } catch (e: any) { + setStatus('error'); + setError(e?.message ?? null); + try { + popup?.close(); + } catch { + /* noop */ + } + } + }, [clearTimer, poll]); + + // Manual "check now" fallback (e.g. if the popup's postMessage/redirect is missed). + const checkNow = useCallback(() => { + const requestId = requestIdRef.current; + if (!requestId || stoppedRef.current) return; + clearTimer(); + poll(requestId, intervalMsRef.current); + }, [clearTimer, poll]); + + return { status, error, start, checkNow }; +} diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 510c11a1da..ad8ca660c8 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1935,6 +1935,7 @@ Example: Virtual Hosted Style`, save: 'Save', search: 'Search', availableModels: 'Available models', + recommended: 'Recommended', profile: 'Profile', avatar: 'Avatar', avatarTip: 'This will be displayed on your profile.', @@ -1990,6 +1991,13 @@ Example: Virtual Hosted Style`, apiKeyMessage: 'Please enter the API Key', apiKeyTip: 'The API Key can be obtained by registering the corresponding LLM supplier.', + aimlapiGetKey: 'Get API key', + aimlapiCheckStatus: 'Check status', + aimlapiAwaitingConsent: 'Waiting for approval in the AI/ML API window…', + aimlapiKeyAdded: 'Your AI/ML API key has been generated and added above.', + aimlapiAuthDenied: 'Authorization was denied. Please try again.', + aimlapiAuthExpired: 'The request expired. Please try again.', + aimlapiAuthFailed: 'Sign-in failed. Please try again.', showMoreModels: 'View models', hideModels: 'Hide models', baseUrl: 'Base URL', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index eafda7224d..b8654a1f25 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1628,6 +1628,7 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 save: '保存', search: '搜索', availableModels: '可选模型', + recommended: '推荐', profile: '概要', avatar: '头像', avatarTip: '这会在你的个人主页展示', @@ -1679,6 +1680,13 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 apiKey: 'API Key', apiKeyMessage: '请输入API Key', apiKeyTip: 'API Key可以通过注册相应的LLM供应商来获取。', + aimlapiGetKey: '获取 API Key', + aimlapiCheckStatus: '检查状态', + aimlapiAwaitingConsent: '正在等待 AI/ML API 窗口中的授权…', + aimlapiKeyAdded: '您的 AI/ML API Key 已生成并已填入上方。', + aimlapiAuthDenied: '授权被拒绝,请重试。', + aimlapiAuthExpired: '请求已过期,请重试。', + aimlapiAuthFailed: '登录失败,请重试。', showMoreModels: '展示更多模型', hideModels: '隐藏模型', baseUrl: 'Base URL', diff --git a/web/src/pages/user-setting/setting-model/instance-card/aimlapi-get-key-button.tsx b/web/src/pages/user-setting/setting-model/instance-card/aimlapi-get-key-button.tsx new file mode 100644 index 0000000000..e51ac5ed2b --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/aimlapi-get-key-button.tsx @@ -0,0 +1,73 @@ +import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; +import { + AimlapiAuthorizeStatus, + useAimlapiAuthorize, +} from '@/hooks/use-aimlapi-authorize'; +import { cn } from '@/lib/utils'; +import { KeyRound, Loader2 } from 'lucide-react'; +import { FC } from 'react'; + +const AimlapiStatusTextKey: Partial> = { + awaiting_consent: 'aimlapiAwaitingConsent', + success: 'aimlapiKeyAdded', + denied: 'aimlapiAuthDenied', + expired: 'aimlapiAuthExpired', + error: 'aimlapiAuthFailed', +}; + +/** + * "Get API key" control for the AIMLAPI provider dialog. Runs the AIMLAPI + * agent-authorization (OAuth device grant) flow and, on success, hands the + * issued key back to the modal via `onKey` so it can be filled into the form. + */ +export const AimlapiGetKeyButton: FC<{ onKey: (apiKey: string) => void }> = ({ + onKey, +}) => { + const { t } = useTranslation('translation', { keyPrefix: 'setting' }); + const { status, error, start, checkNow } = useAimlapiAuthorize({ onKey }); + + const busy = status === 'starting' || status === 'awaiting_consent'; + const statusKey = AimlapiStatusTextKey[status]; + + return ( +
+
+ + {status === 'awaiting_consent' && ( + + )} +
+ {statusKey && ( + + {t(statusKey)} + {status === 'error' && error ? `: ${error}` : ''} + + )} +
+ ); +}; + +export default AimlapiGetKeyButton; diff --git a/web/src/pages/user-setting/setting-model/instance-card/available-models.tsx b/web/src/pages/user-setting/setting-model/instance-card/available-models.tsx index c011c9cf44..bb3ac4ca3e 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/available-models.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/available-models.tsx @@ -18,9 +18,10 @@ import { LlmIcon } from '@/components/svg-icon'; import { Button } from '@/components/ui/button'; import { SearchInput } from '@/components/ui/input'; -import { APIMapUrl } from '@/constants/llm'; +import { APIMapUrl, LLMFactory } from '@/constants/llm'; import { useFetchAvailableProviders } from '@/hooks/use-llm-request'; -import { ArrowUpRight, Plus } from 'lucide-react'; +import { sortLLmFactoryListBySpecifiedOrder } from '@/utils/common-util'; +import { ArrowUpRight, Plus, Star } from 'lucide-react'; import { FC, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -82,12 +83,16 @@ export const AvailableModels: FC<{ }, [factoryList, searchTerm]); const filteredModels = useMemo(() => { - if (selectedTag === null) { - return searchedModels; - } - return searchedModels.filter((model) => - model.model_types.some((type) => type === selectedTag), - ); + const filtered = + selectedTag === null + ? searchedModels + : searchedModels.filter((model) => + model.model_types.some((type) => type === selectedTag), + ); + // AIMLAPI first (then RAGFlow's curated order) in the available list + return sortLLmFactoryListBySpecifiedOrder( + filtered as any, + ) as unknown as typeof filtered; }, [searchedModels, selectedTag]); // Number of providers matching each tag, respecting the current search term so @@ -172,7 +177,7 @@ export const AvailableModels: FC<{ {/* Models List */} -
+
{filteredModels.map((model) => (
handleAddModel(model.name)} > -
- -
+
+ +
{model.name}
@@ -192,7 +200,7 @@ export const AvailableModels: FC<{ asLink variant="ghost" size="icon-xs" - className="size-4" + className="size-4 shrink-0" to={APIMapUrl[model.name as keyof typeof APIMapUrl]} target="_blank" rel="noopener noreferrer" @@ -203,11 +211,27 @@ export const AvailableModels: FC<{ )} + {model.name === LLMFactory.AIMLAPI && ( + // Recommended badge: the star always shows; the label appears + // only when the card (container) is wide enough to fit it + // alongside the Add button — otherwise it collapses to just + // the star. + + + + {t('setting.recommended')} + + + )}