diff --git a/package.json b/package.json index 7eff551..49837c1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "author": "abearxiong ", "license": "MIT", "dependencies": { + "@ai-sdk/anthropic": "^3.0.38", + "@ai-sdk/openai": "^3.0.26", + "@ai-sdk/openai-compatible": "^2.0.28", "@kevisual/cnb": "^0.0.19", "@kevisual/context": "^0.0.4", "@kevisual/router": "0.0.70", @@ -29,6 +32,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-router": "^1.158.1", + "ai": "^6.0.77", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", @@ -40,6 +44,7 @@ "react-dom": "^19.2.4", "react-hook-form": "^7.71.1", "sonner": "^2.0.7", + "zod": "^4.3.6", "zustand": "^5.0.11" }, "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6525962..7854d07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.38 + version: 3.0.38(zod@4.3.6) + '@ai-sdk/openai': + specifier: ^3.0.26 + version: 3.0.26(zod@4.3.6) + '@ai-sdk/openai-compatible': + specifier: ^2.0.28 + version: 2.0.28(zod@4.3.6) '@kevisual/cnb': specifier: ^0.0.19 version: 0.0.19(dotenv@17.2.3)(idb-keyval@6.2.1) @@ -44,6 +53,9 @@ importers: '@tanstack/react-router': specifier: ^1.158.1 version: 1.158.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + ai: + specifier: ^6.0.77 + version: 6.0.77(zod@4.3.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -77,6 +89,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + zod: + specifier: ^4.3.6 + version: 4.3.6 zustand: specifier: ^5.0.11 version: 5.0.11(@types/react@19.2.13)(immer@10.1.1)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -169,6 +184,40 @@ importers: packages: + '@ai-sdk/anthropic@3.0.38': + resolution: {integrity: sha512-9MchyPRPni0WzrFeIGNevZpQVfWxaS+MQFupIXYQo9VgHnuO1Vyrp9SBmjkkuoAdBs7GomsWqLZCcNMJAVbdFA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@3.0.39': + resolution: {integrity: sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai-compatible@2.0.28': + resolution: {integrity: sha512-WzDnU0B13FMSSupDtm2lksFZvWGXnOfhG5S0HoPI0pkX5uVkr6N1UTATMyVaxLCG0MRkMhXCjkg4NXgEbb330Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@3.0.26': + resolution: {integrity: sha512-W/hiwxIfG29IO0Fob1HwWpFssMsNrxWoX8A7DwNGOtKArDBmJNuGzQeU/k0Fnh8WyvZEnfxkjO4oXkSXfVBayg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.14': + resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -703,6 +752,10 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@oxc-project/runtime@0.112.0': resolution: {integrity: sha512-4vYtWXMnXM6EaweCxbJ6bISAhkNHeN33SihvuX3wrpqaSJA4ZEoW35i9mSvE74+GDf1yTeVE+aEHA+WBpjDk/g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1349,6 +1402,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -1571,6 +1627,10 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@5.1.3': resolution: {integrity: sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1582,6 +1642,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ai@6.0.77: + resolution: {integrity: sha512-tyyhrRpCRFVlivdNIFLK8cexSBB2jwTqO0z1qJQagk+UxZ+MW8h5V8xsvvb+xdKDY482Y8KAm0mr7TDnPKvvlw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -1753,6 +1819,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + fdir@6.4.4: resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} peerDependencies: @@ -1894,6 +1964,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2625,6 +2698,42 @@ packages: snapshots: + '@ai-sdk/anthropic@3.0.38(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/gateway@3.0.39(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + + '@ai-sdk/openai-compatible@2.0.28(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/openai@3.0.26(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.14(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -3212,6 +3321,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@opentelemetry/api@1.9.0': {} + '@oxc-project/runtime@0.112.0': {} '@oxc-project/types@0.112.0': {} @@ -3745,6 +3856,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3971,6 +4084,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@5.1.3(vite@8.0.0-beta.13(@types/node@25.2.1)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.5.1))': dependencies: '@babel/core': 7.29.0 @@ -3985,6 +4100,14 @@ snapshots: acorn@8.15.0: {} + ai@6.0.77(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.39(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + ansis@4.2.0: {} anymatch@3.1.3: @@ -4161,6 +4284,8 @@ snapshots: eventemitter3@5.0.4: {} + eventsource-parser@3.0.6: {} + fdir@6.4.4(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -4276,6 +4401,8 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema@0.4.0: {} + json5@2.2.3: {} lightningcss-android-arm64@1.30.2: diff --git a/src/app/config/page.tsx b/src/app/config/page.tsx new file mode 100644 index 0000000..33e6c1f --- /dev/null +++ b/src/app/config/page.tsx @@ -0,0 +1,116 @@ +import { useConfigStore } from './store'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { configSchema } from './store/schema'; + +export const ConfigPage = () => { + const { config, setConfig, resetConfig } = useConfigStore(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const result = configSchema.safeParse(config); + if (result.success) { + console.log('配置已保存:', config); + // 可以在此处添加 toast 通知 + } else { + console.error('验证错误:', result.error.format()); + } + }; + + const handleChange = (field: keyof typeof config, value: string) => { + setConfig({ [field]: value }); + }; + + return ( +
+ + + CNB 配置 + + 配置您的 CNB API 设置。这些设置会保存在浏览器的本地存储中。 + + + +
+
+ + handleChange('CNB_API_KEY', e.target.value)} + placeholder="请输入您的 CNB API 密钥" + /> +
+ +
+ + handleChange('CNB_COOKIE', e.target.value)} + placeholder="请输入您的 CNB Cookie" + /> +
+ +
+ + handleChange('CNB_CORS_URL', e.target.value)} + placeholder="https://cors.example.com" + /> +
+ +
+ + handleChange('AI_BASE_URL', e.target.value)} + placeholder="请输入 AI 基础地址" + /> +
+ +
+ + handleChange('AI_MODEL', e.target.value)} + placeholder="请输入 AI 模型名称" + /> +
+ +
+ + handleChange('AI_API_KEY', e.target.value)} + placeholder="请输入您的 AI API 密钥" + /> +
+ +
+ + +
+
+
+
+
+ ); +}; + +export default ConfigPage; diff --git a/src/app/config/store/index.ts b/src/app/config/store/index.ts new file mode 100644 index 0000000..7120ba3 --- /dev/null +++ b/src/app/config/store/index.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { Config, defaultConfig } from './schema'; + +type ConfigState = { + config: Config; + setConfig: (config: Partial) => void; + resetConfig: () => void; +}; + +const STORAGE_KEY = 'cnb-config'; + +const loadInitialConfig = (): Config => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch { + // Ignore parse errors + } + return { + CNB_API_KEY: '', + CNB_COOKIE: '', + CNB_CORS_URL: 'https://cors.kevisual.cn', + AI_BASE_URL: '', + AI_MODEL: '', + AI_API_KEY: '' + }; +}; + +export const useConfigStore = create()( + persist( + (set) => ({ + config: loadInitialConfig(), + setConfig: (newConfig) => + set((state) => ({ + config: { ...state.config, ...newConfig }, + })), + resetConfig: () => + set({ + config: { + CNB_API_KEY: '', + CNB_COOKIE: '', + CNB_CORS_URL: 'https://cors.kevisual.cn', + AI_BASE_URL: 'https://api.cnb.cool/kevisual/cnb-ai/-/ai/', + AI_MODEL: 'CNB-Models', + AI_API_KEY: '' + }, + }), + }), + { + name: STORAGE_KEY, + } + ) +); diff --git a/src/app/config/store/schema.ts b/src/app/config/store/schema.ts new file mode 100644 index 0000000..d04ae7a --- /dev/null +++ b/src/app/config/store/schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const configSchema = z.object({ + CNB_API_KEY: z.string().min(1, 'API Key is required'), + CNB_COOKIE: z.string().min(1, 'Cookie is required'), + CNB_CORS_URL: z.url('Must be a valid URL'), + AI_BASE_URL: z.url('Must be a valid URL'), + AI_MODEL: z.string().min(1, 'AI Model is required'), + AI_API_KEY: z.string().min(1, 'AI API Key is required'), +}); + +export type Config = z.infer; + +export const defaultConfig: Config = { + CNB_API_KEY: '', + CNB_COOKIE: '', + CNB_CORS_URL: 'https://cors.kevisual.cn', + AI_BASE_URL: '', + AI_MODEL: '', + AI_API_KEY: '' +}; diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..4ac0b5b --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,43 @@ +import { CNB, Issue } from '@kevisual/cnb' +import { useLayoutEffect } from 'react' +import { useConfigStore } from './config/store' +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { generateText } from 'ai'; +const init2 = async () => { + const cnb = new CNB({ + token: 'cIDfLOOIr1Trt15cdnwfndupEZG', + cookie: 'CNBSESSION=1770014410.1935321989751226368.7f386c282d80efb5256180ef94c2865e20a8be72e2927a5f8eb1eb72142de39f;csrfkey=2028873452', + cors: { + baseUrl: 'https://cors.kevisual.cn' + } + }) + // const res = await cnb.issue.getList('kevisual/kevisual') + // console.log('res', res) + const token = await cnb.user.getCurrentUser() + console.log('token', token) +} + +const initAi = async () => { + const state = useConfigStore.getState() + const config = state.config + const cors = state.config.CNB_CORS_URL + const base = cors + '/' + config.AI_BASE_URL.replace('https://', '') + const cnb = createOpenAICompatible({ + baseURL: base, + name: 'custom-cnb', + apiKey: config.AI_API_KEY, + }); + const model = config.AI_MODEL; + // const model = 'hunyuan'; + const { text } = await generateText({ + model: cnb(model), + prompt: '你好', + }); + console.log('text', text) +} +export const Home = () => { + useLayoutEffect(() => { initAi() }, []) + return
Home Page
+} + +export default Home; \ No newline at end of file diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx deleted file mode 100644 index 7d10138..0000000 --- a/src/pages/Home.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { CNB, Issue } from '@kevisual/cnb/src/index.ts' -import { useLayoutEffect } from 'react' -const init2 = async () => { - const cnb = new CNB({ - token: 'cIDfLOOIr1Trt15cdnwfndupEZG', - cookie: 'CNBSESSION=1770014410.1935321989751226368.7f386c282d80efb5256180ef94c2865e20a8be72e2927a5f8eb1eb72142de39f;csrfkey=2028873452', - cors: { - baseUrl: 'https://cors.kevisual.cn' - } - }) - // const res = await cnb.issue.getList('kevisual/kevisual') - // console.log('res', res) - const token = await cnb.user.getCurrentUser() - console.log('token', token) -} -export const Home = () => { - useLayoutEffect(() => { init2() }, []) - return
Home Page
-} \ No newline at end of file diff --git a/src/pages/config/index.tsx b/src/pages/config/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d204c26..d0a1254 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,8 +9,14 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as ConfigRouteImport } from './routes/config' import { Route as IndexRouteImport } from './routes/index' +const ConfigRoute = ConfigRouteImport.update({ + id: '/config', + path: '/config', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/config': typeof ConfigRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/config': typeof ConfigRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/config': typeof ConfigRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/config' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/config' + id: '__root__' | '/' | '/config' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ConfigRoute: typeof ConfigRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/config': { + id: '/config' + path: '/config' + fullPath: '/config' + preLoaderRoute: typeof ConfigRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -53,6 +70,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ConfigRoute: ConfigRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/config.tsx b/src/routes/config.tsx new file mode 100644 index 0000000..5c0a256 --- /dev/null +++ b/src/routes/config.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' +import Home from '@/app/config/page' +export const Route = createFileRoute('/config')({ + component: RouteComponent, +}) + +function RouteComponent() { + return +} \ No newline at end of file diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 21688d1..af2f49e 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { Home } from '@/pages/Home' +import Home from '@/app/page' export const Route = createFileRoute('/')({ component: RouteComponent, }) diff --git a/test/ai.ts b/test/ai.ts new file mode 100644 index 0000000..9754866 --- /dev/null +++ b/test/ai.ts @@ -0,0 +1,17 @@ +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { generateText } from 'ai'; + + +const cnb = createOpenAICompatible({ + baseURL: 'https://api.cnb.cool/kevisual/cnb-ai/-/ai', + name: 'custom-cnb', + apiKey: 'cIDfLOOIr1Trt15cdnwfndupEZG', +}); +// const model = config.AI_MODEL; +const model = 'hunyuan'; +const { text } = await generateText({ + model: cnb(model), + prompt: 'Say hello in one sentence.', +}); +console.log('text', text) +// https://api.cnb.cool \ No newline at end of file