diff --git a/VERSION b/VERSION index 4b9fcbe..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.6.0 diff --git a/app/adapters/ai.py b/app/adapters/ai.py index c97b5b7..6c7d3d2 100644 --- a/app/adapters/ai.py +++ b/app/adapters/ai.py @@ -103,6 +103,31 @@ class OpenAIAdapter(_AIBase): # get_account 为 False,刷新余额时会如实提示「暂不支持余额查询」。 +@register("agnes-api") +class AgnesAdapter(_AIBase): + """Agnes 适配器(OpenAI 兼容接口,Bearer Key) + + 官方 Base URL:https://api.agnes-ai.cn/v1(国际站为 api.agnes-ai.com) + 只做 Key 有效性验证与可用模型查询;官方没有公开余额接口, + 因此不实现 get_account——否则余额刷新会显示「已更新」但数值永远不变。 + """ + + BASE = "https://api.agnes-ai.cn/v1" + + def _base_url(self) -> str: + return (self.config.get("base_url") or self.BASE).rstrip("/") + + def test_connection(self) -> dict: + try: + data = self._get(self._base_url() + "/models") + count = len(data.get("data", [])) + return {"ok": True, "message": f"连接成功,可用模型 {count} 个"} + except httpx.HTTPStatusError as e: + return self._http_error(e) + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + @register("minimax-api") class MinimaxAdapter(_AIBase): """Minimax 适配器(余额查询) diff --git a/app/adapters/base.py b/app/adapters/base.py index 05b774c..5d18770 100644 --- a/app/adapters/base.py +++ b/app/adapters/base.py @@ -228,6 +228,8 @@ class BaseAdapter(ABC): #: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"]) required_config: list = [] + #: required_config 中不属于密钥的字段(前端按明文输入,避免代理号/域名被遮住) + plain_config: list = [] def __init__(self, config: Optional[dict] = None): self.config = config or {} diff --git a/app/adapters/cloudcone.py b/app/adapters/cloudcone.py new file mode 100644 index 0000000..831a035 --- /dev/null +++ b/app/adapters/cloudcone.py @@ -0,0 +1,107 @@ +"""CloudCone 适配器(REST API v2) + +API 文档:https://cloudcone-api.readme.io/reference +所需配置:{"api_key": "...", "api_hash": "..."} +认证请求头:App-Secret = API Key,Hash = API Hash + +覆盖:实例清单、开机/关机/重启。 +CloudCone 没有公开的账单/到期/余额接口,故不声明 get_account, +也不提供续费动作——拿不到到期日就别假装能核对。 +""" + +import httpx + +from app.adapters.base import BaseAdapter, NormalizedVPS +from app.adapters.registry import register + +#: CloudCone state.name -> 台账状态 +_STATUS_MAP = { + "active": "active", + "starting": "active", + "rebooting": "active", + "stopped": "stopped", + "shutdown": "stopped", + "suspended": "stopped", +} + + +@register("cloudcone-api") +class CloudConeAdapter(BaseAdapter): + required_config = ["api_key", "api_hash"] + BASE = "https://api.cloudcone.com/api/v2" + + def _headers(self) -> dict: + return { + "App-Secret": self.config.get("api_key", ""), + "Hash": self.config.get("api_hash", ""), + } + + def _request(self, method: str, path: str) -> dict: + with httpx.Client(timeout=30) as client: + resp = client.request(method, self.BASE + path, headers=self._headers()) + resp.raise_for_status() + data = resp.json() if resp.content else {} + # CloudCone 用 status=1 表示成功,0 时 message 里才有可读原因 + if isinstance(data, dict) and data.get("status") not in (1, "1", None): + raise RuntimeError(data.get("message") or "CloudCone 接口返回失败") + return data + + def _get(self, path: str) -> dict: + return self._request("GET", path) + + def _post(self, path: str) -> dict: + return self._request("POST", path) + + def test_connection(self) -> dict: + try: + instances = self.list_vps() + return {"ok": True, "message": f"连接成功,读取到 {len(instances)} 台实例"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key/Hash 无效或权限不足"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + def list_vps(self) -> list: + data = self._get("/compute/instances") + raw_instances = (data.get("__data") or {}).get("instances") or [] + result = [] + for inst in raw_instances: + state = (inst.get("state") or {}).get("name") or "unknown" + ips = str(inst.get("ips") or "").strip() + result.append( + NormalizedVPS( + external_id=str(inst.get("id")), + name=inst.get("hostname") or inst.get("nodename") or str(inst.get("id")), + ip_address=ips.split(",")[0].strip() if ips else None, + region=inst.get("nodename"), + os=inst.get("distro"), + cpu_cores=inst.get("cpu"), + memory_gb=inst.get("ram"), + disk_gb=inst.get("disk"), + status=_STATUS_MAP.get(state.lower(), "unknown"), + currency="USD", + charge_type="prepaid", + raw=inst, + ) + ) + return result + + # ---------------- 实例管理动作 ---------------- + + def _instance_action(self, external_id: str, verb: str, label: str) -> dict: + try: + self._post(f"/compute/{external_id}/{verb}") + return {"ok": True, "message": f"{label}指令已下发"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{label}失败:{e}"} + + def start_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "boot", "开机") + + def stop_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "shutdown", "关机") + + def reboot_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "reboot", "重启") diff --git a/app/adapters/dynadot.py b/app/adapters/dynadot.py new file mode 100644 index 0000000..8fdf344 --- /dev/null +++ b/app/adapters/dynadot.py @@ -0,0 +1,127 @@ +"""Dynadot 适配器(Domain API v3,API Key 查询参数) + +API 文档:https://www.dynadot.com/domain/api-commands +所需配置:{"api_key": "..."} + +覆盖:注册域名清单(含到期日)、账户余额。 +Dynadot 的 DNS 记录通过 set_dns2 的 main_record0..19 位置参数写入, +无法安全地做「按记录增删改」,故不声明 DNS 能力。 +""" + +from datetime import date, datetime, timezone + +import httpx + +from app.adapters.base import ( + AccountInfo, + BaseAdapter, + NormalizedDomain, + parse_iso_date, +) +from app.adapters.registry import register + + +def _epoch_ms_to_date(value) -> date | None: + """Dynadot 的 Expiration 是 Unix 毫秒字符串;兼容秒级和 ISO 字符串""" + if value in (None, ""): + return None + text = str(value).strip() + try: + num = int(float(text)) + except ValueError: + return parse_iso_date(text) + if num > 10_000_000_000: # 毫秒 + num //= 1000 + try: + return datetime.fromtimestamp(num, tz=timezone.utc).date() + except (ValueError, OSError, OverflowError): + return None + + +def _as_list(value) -> list: + if isinstance(value, list): + return value + if isinstance(value, dict): + return [value] + return [] + + +@register("dynadot-api") +class DynadotAdapter(BaseAdapter): + required_config = ["api_key"] + BASE = "https://api.dynadot.com/api3.json" + + def _call(self, command: str, params: dict | None = None) -> dict: + query = {"key": self.config.get("api_key", ""), "command": command} + if params: + query.update({k: v for k, v in params.items() if v is not None}) + with httpx.Client(timeout=30) as client: + resp = client.get(self.BASE, params=query) + resp.raise_for_status() + data = resp.json() + + # 每个命令的根键不同(ListDomainInfoResponse / GetAccountBalanceResponse…) + root = next( + (v for k, v in data.items() if isinstance(v, dict) and k.endswith("Response")), + data if isinstance(data, dict) else {}, + ) + code = root.get("ResponseCode", root.get("SuccessCode")) + status_text = str(root.get("Status") or "").lower() + if str(code) != "0" or status_text == "error": + reason = root.get("Error") or root.get("Message") or root.get("Status") or "未知错误" + raise RuntimeError(f"Dynadot {command} 失败(code={code}):{reason}") + return root + + def test_connection(self) -> dict: + try: + acc = self.get_account() + if acc.balance is None: + return {"ok": True, "message": "连接成功(账户未返回余额)"} + return {"ok": True, "message": f"连接成功,账户余额 {acc.balance} {acc.currency}"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key 无效"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + # ---------------- 只读清单 ---------------- + + def list_domains(self) -> list: + root = self._call("list_domain") + result = [] + for entry in _as_list(root.get("MainDomains")): + # JSON 里可能是 {"Domain": {...}},也可能直接把字段摊平 + node = entry.get("Domain") if isinstance(entry.get("Domain"), dict) else entry + name = node.get("Name") or entry.get("Name") + if not name: + continue + expiry = _epoch_ms_to_date(node.get("Expiration") or entry.get("Expiration")) + status = str(node.get("Status") or entry.get("Status") or "active").lower() + result.append( + NormalizedDomain( + external_id=str(name), + domain_name=str(name), + registrar="dynadot", + expiry_date=expiry, + status=status, + raw=entry, + ) + ) + return result + + def get_account(self) -> AccountInfo: + root = self._call("get_account_balance") + balances = _as_list(root.get("BalanceList")) + chosen = next( + (b for b in balances if str(b.get("Currency", "")).upper() == "USD"), + balances[0] if balances else {}, + ) + amount = chosen.get("Amount") + try: + balance = float(str(amount).replace(",", "")) if amount not in (None, "") else None + except (TypeError, ValueError): + balance = None + return AccountInfo( + balance=balance, + currency=str(chosen.get("Currency") or "USD").upper(), + raw=root, + ) diff --git a/app/adapters/registry.py b/app/adapters/registry.py index 8faf0a9..fb27ca0 100644 --- a/app/adapters/registry.py +++ b/app/adapters/registry.py @@ -29,13 +29,17 @@ def load_all() -> None: ai, aliyun, aws, + cloudcone, cloudflare, digitalocean, + dynadot, linode, namesilo, tencent, vultr, whmcs, + xinnet, + zeabur, ) @@ -84,6 +88,7 @@ def adapter_meta() -> list: "class": cls.__name__, "sdk_types": getattr(cls, "sdk_types", [sdk_type]), "required_config": getattr(cls, "required_config", []), + "plain_config": getattr(cls, "plain_config", []), } ) return result diff --git a/app/adapters/xinnet.py b/app/adapters/xinnet.py new file mode 100644 index 0000000..fc451ab --- /dev/null +++ b/app/adapters/xinnet.py @@ -0,0 +1,122 @@ +"""新网(Xinnet)代理 API 适配器 + +API 文档:新网「域名代理 API」(api.xinnet.com/domain/api.gb) +所需配置:{"client": "代理号", "password": "API 密码", "domains": "a.com,b.com"} + +限制(必须如实告知,不能假装能一键同步): +- 只有新网代理账号能调用,且必须在代理后台登记调用来源 IP,API 每年需年审; +- 接口没有「列出全部域名」的方法,必须由用户提供要核对的域名清单; +- 新网零售客户区登录带腾讯验证码,无法用面板抓取替代。 + +覆盖:按配置的域名逐个查询到期日(GetProductKey / keyname=ExpireDate)。 +响应不是 JSON,而是 URL 编码的 key=value 串,成功时形如 +``name=example.com&key=2028-03-01``,失败时形如 ``ret=0&err=client-noexist``。 +""" + +import hashlib +import re +from urllib.parse import parse_qsl + +import httpx + +from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date +from app.adapters.registry import register + +_DOMAIN_SPLIT_RE = re.compile(r"[,,;;\s]+") + + +@register("xinnet-api") +class XinnetAdapter(BaseAdapter): + required_config = ["client", "password", "domains"] + #: 代理号和域名清单不是密钥,前端按明文输入 + plain_config = ["client", "domains"] + BASE = "https://api.xinnet.com/domain/api.gb" + + def _sign(self, method: str, parts: list) -> str: + raw = method + str(self.config.get("client") or "") + str( + self.config.get("password") or "" + ) + "".join(parts) + return hashlib.md5(raw.encode("utf-8")).hexdigest() + + def _post(self, method: str, params: dict, sign_parts: list) -> dict: + payload = { + "client": self.config.get("client") or "", + **params, + "checksum": self._sign(method, sign_parts), + } + with httpx.Client(timeout=30) as client: + resp = client.post( + self.BASE, + params={"method": method, "charset": "utf-8"}, + data=payload, + ) + resp.raise_for_status() + data = dict(parse_qsl(resp.text.strip(), keep_blank_values=True)) + + ret = data.get("ret") + if ret is not None and str(ret) != "100": + reason = data.get("err") or data.get("info") or "未知错误" + raise RuntimeError(f"新网 {method} 失败(ret={ret}):{reason}") + return data + + @staticmethod + def _enc(domain: str) -> str: + return "G" if any(ord(ch) > 127 for ch in domain) else "E" + + def _domains(self) -> list: + raw = str(self.config.get("domains") or "") + seen = set() + result = [] + for item in _DOMAIN_SPLIT_RE.split(raw): + domain = item.strip().lower() + if domain and domain not in seen: + seen.add(domain) + result.append(domain) + return result + + def _expiry(self, domain: str) -> str | None: + enc = self._enc(domain) + data = self._post( + "GetProductKey", + {"name": domain, "enc": enc, "keyname": "ExpireDate"}, + [domain, enc], + ) + return data.get("key") or data.get("ExpireDate") + + def test_connection(self) -> dict: + domains = self._domains() + if not domains: + return {"ok": False, "message": "请填写要核对的新网域名(逗号分隔)"} + try: + expiry = self._expiry(domains[0]) + if not expiry: + return {"ok": False, "message": f"接口已连通,但未返回 {domains[0]} 的到期日"} + return { + "ok": True, + "message": f"连接成功,{domains[0]} 到期 {expiry}(共 {len(domains)} 个域名)", + } + except httpx.HTTPStatusError as e: + return { + "ok": False, + "message": f"HTTP {e.response.status_code}:请确认代理号、API 密码与来源 IP 白名单", + } + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + def list_domains(self) -> list: + result = [] + for domain in self._domains(): + expiry_text = self._expiry(domain) + if not expiry_text: + raise RuntimeError(f"新网未返回 {domain} 的到期日,请确认该域名属于此代理账号") + result.append( + NormalizedDomain( + external_id=domain, + domain_name=domain, + registrar="xinnet", + expiry_date=parse_iso_date(expiry_text), + status="active", + raw={"domain": domain, "expiry": expiry_text}, + ) + ) + return result diff --git a/app/adapters/zeabur.py b/app/adapters/zeabur.py new file mode 100644 index 0000000..4d1324a --- /dev/null +++ b/app/adapters/zeabur.py @@ -0,0 +1,119 @@ +"""Zeabur 适配器(GraphQL Public API,Bearer Token) + +API 文档:https://zeabur.com/docs/zh-CN/developer/public-api +端点:https://api.zeabur.com/graphql +所需配置:{"api_token": "..."} + +覆盖:团队/个人名下的服务器清单、重启服务器。 +服务器列表返回 CPU(毫核)、内存/磁盘(MB)与在线状态; +Zeabur 的计费走用量结算,没有统一的「到期日/余额」查询,故不声明 +get_account,也不提供续费。 +""" + +import httpx + +from app.adapters.base import BaseAdapter, NormalizedVPS +from app.adapters.registry import register + +_SERVERS_QUERY = """ +query Servers { + servers { + _id + name + ip + country + city + provisioningStatus + providerInfo { code name } + hasK3s + status { + isOnline + totalCPU + totalMemory + totalDisk + vmStatus + } + } +} +""" + + +@register("zeabur-api") +class ZeaburAdapter(BaseAdapter): + required_config = ["api_token"] + BASE = "https://api.zeabur.com/graphql" + + def _graphql(self, query: str, variables: dict | None = None) -> dict: + payload = {"query": query} + if variables: + payload["variables"] = variables + with httpx.Client(timeout=30) as client: + resp = client.post( + self.BASE, + headers={"Authorization": f"Bearer {self.config.get('api_token', '')}"}, + json=payload, + ) + resp.raise_for_status() + data = resp.json() + errors = data.get("errors") or [] + if errors: + message = errors[0].get("message") if isinstance(errors[0], dict) else str(errors[0]) + raise RuntimeError(f"Zeabur GraphQL 错误:{message}") + return data.get("data") or {} + + def test_connection(self) -> dict: + try: + data = self._graphql("query Me { me { username } }") + username = (data.get("me") or {}).get("username") or "未知账户" + return {"ok": True, "message": f"连接成功,令牌属于 {username}"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:API Token 无效或权限不足"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + def list_vps(self) -> list: + servers = self._graphql(_SERVERS_QUERY).get("servers") or [] + result = [] + for server in servers: + status = server.get("status") or {} + online = bool(status.get("isOnline")) + provider = (server.get("providerInfo") or {}).get("name") + location = ", ".join( + part for part in (server.get("city"), server.get("country")) if part + ) + total_cpu = int(status.get("totalCPU") or 0) + result.append( + NormalizedVPS( + external_id=str(server.get("_id")), + name=server.get("name") or server.get("ip") or str(server.get("_id")), + ip_address=server.get("ip") or None, + region=location or provider, + # hasK3s==False 才是纯净 Ubuntu;nil 是旧机器,默认装了 ZeaburOS + os="Ubuntu" if server.get("hasK3s") is False else "ZeaburOS", + # totalCPU 单位是毫核(4000 = 4 核),台账列是整数核 + cpu_cores=max(1, round(total_cpu / 1000)) if total_cpu else None, + memory_gb=round(int(status.get("totalMemory") or 0) / 1024, 1) or None, + disk_gb=round(int(status.get("totalDisk") or 0) / 1024) or None, + status="active" if online else "stopped", + currency="USD", + charge_type="postpaid", + raw=server, + ) + ) + return result + + def reboot_instance(self, external_id: str) -> dict: + mutation = """ + mutation RebootServer($id: ObjectID!) { + rebootServer(_id: $id) + } + """ + try: + data = self._graphql(mutation, {"id": external_id}) + if not data.get("rebootServer"): + return {"ok": False, "message": "重启请求未被接受"} + return {"ok": True, "message": "重启指令已下发"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:重启失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"重启失败:{e}"} diff --git a/app/core/seed.py b/app/core/seed.py index d385de7..f525403 100644 --- a/app/core/seed.py +++ b/app/core/seed.py @@ -39,25 +39,24 @@ PRESET_PROVIDERS = [ {"slug": "tothost", "name": "TotHost", "name_en": "TotHost", "category": "vps", "services": "vps", "website": "https://www.tothost.vn", "console_url": "https://billing.tothost.vn/clientarea.php", "sdk_type": "whmcs-panel"}, - # 以下平台暂无适配器:sdk_type 必须留空。 - # 填了不存在的 sdk_type 会让平台卡片显示「API 对接: xxx」,用户建账号点同步才撞 - # 「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。 {"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps", "services": "vps", - "website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": None}, + "website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"}, {"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps", "services": "vps,domain", - "website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": None}, + "website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"}, # ---- 域名注册商 ---- {"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain", "services": "domain,dns", "website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"}, + # 新网只有代理账号能调 API,且没有「列出全部域名」的接口:需在账号里 + # 填写 domains 清单;零售客户区有验证码,无法用面板抓取替代。 {"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain", "services": "domain,dns", - "website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None}, + "website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": "xinnet-api"}, {"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain", "services": "domain,dns", - "website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": None}, + "website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"}, # ---- AI 服务商 ---- {"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent", "services": "ai_agent", @@ -70,7 +69,7 @@ PRESET_PROVIDERS = [ "website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"}, {"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent", "services": "ai_agent", - "website": None, "console_url": None, "sdk_type": None}, + "website": None, "console_url": None, "sdk_type": "agnes-api"}, {"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent", "services": "ai_agent", "website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"}, diff --git a/app/database.py b/app/database.py index a32e8ec..22265a2 100644 --- a/app/database.py +++ b/app/database.py @@ -189,13 +189,13 @@ def _align_preset_providers(conn) -> None: (新增适配器、废弃从未实现的 sdk_type)必须在这里落地,否则线上会长期 停留在旧声明上。 - - sdk_type:库里声明的值不在适配器注册表内就清空。留着会让平台卡片显示 - 「API 对接: xxx」,用户建好账号点同步才撞「暂不支持的 SDK 类型」—— - 声明支持却不支持,比明说不支持更糟。 + - sdk_type:库里声明了但注册表没有的值要清空(声明支持却不支持,比明说不支持 + 更糟);库里为空而种子已给出且已实现的要补上——否则新增适配器上线后, + 线上平台卡片仍停留在「未对接」,用户永远点不到同步。 - services:只增量补标签,不删除。用户可能手动加过标签(线上 aliyun-intl 就多了 ai_agent/other),按种子值整体覆盖会丢用户配置。 - 只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台(如 tothost)不动。 + 只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台不动。 """ import sqlalchemy as sa from app.adapters import registry @@ -213,12 +213,19 @@ def _align_preset_providers(conn) -> None: if not data: continue - declared = row[2] + declared = (row[2] or "").strip() if declared and declared not in supported: conn.execute( sa.text("UPDATE providers SET sdk_type = NULL WHERE id = :id"), {"id": row[0]}, ) + elif not declared: + seed_sdk = (data.get("sdk_type") or "").strip() + if seed_sdk and seed_sdk in supported: + conn.execute( + sa.text("UPDATE providers SET sdk_type = :sdk WHERE id = :id"), + {"sdk": seed_sdk, "id": row[0]}, + ) seed_services = [s.strip() for s in (data.get("services") or "").split(",") if s.strip()] if not seed_services: diff --git a/app/services/sync_service.py b/app/services/sync_service.py index dff9912..d65bf6e 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -395,6 +395,7 @@ _AI_SDK_MAP = { "kimi": "moonshot-api", "moonshot": "moonshot-api", "minimax": "minimax-api", + "agnes": "agnes-api", } diff --git a/static/js/modals.js b/static/js/modals.js index 5a36107..a04a0c5 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -226,7 +226,7 @@ const AccountModal = {
修改账号标识后,引用该账号的资产会自动同步更新。