feat: 添加配置页面和状态管理,集成 AI SDK

This commit is contained in:
2026-02-08 13:11:37 +08:00
parent abd9860a90
commit f117302a98
12 changed files with 416 additions and 23 deletions

View File

@@ -17,6 +17,9 @@
"author": "abearxiong <xiongxiao@xiongxiao.me>",
"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": {

127
pnpm-lock.yaml generated
View File

@@ -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:

116
src/app/config/page.tsx Normal file
View File

@@ -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 (
<div className="container mx-auto max-w-2xl py-8">
<Card>
<CardHeader>
<CardTitle>CNB </CardTitle>
<CardDescription>
CNB API
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="api-key">API </Label>
<Input
id="api-key"
type="text"
value={config.CNB_API_KEY}
onChange={(e) => handleChange('CNB_API_KEY', e.target.value)}
placeholder="请输入您的 CNB API 密钥"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cookie">Cookie</Label>
<Input
id="cookie"
type="text"
value={config.CNB_COOKIE}
onChange={(e) => handleChange('CNB_COOKIE', e.target.value)}
placeholder="请输入您的 CNB Cookie"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cors-url"></Label>
<Input
id="cors-url"
type="url"
value={config.CNB_CORS_URL}
onChange={(e) => handleChange('CNB_CORS_URL', e.target.value)}
placeholder="https://cors.example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">AI </Label>
<Input
id="ai-base-url"
type="url"
value={config.AI_BASE_URL}
onChange={(e) => handleChange('AI_BASE_URL', e.target.value)}
placeholder="请输入 AI 基础地址"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">AI </Label>
<Input
id="ai-model"
type="text"
value={config.AI_MODEL}
onChange={(e) => handleChange('AI_MODEL', e.target.value)}
placeholder="请输入 AI 模型名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">AI </Label>
<Input
id="ai-api-key"
type="password"
value={config.AI_API_KEY}
onChange={(e) => handleChange('AI_API_KEY', e.target.value)}
placeholder="请输入您的 AI API 密钥"
/>
</div>
<div className="flex gap-4">
<Button type="submit"></Button>
<Button type="button" variant="outline" onClick={resetConfig}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default ConfigPage;

View File

@@ -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<Config>) => 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<ConfigState>()(
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,
}
)
);

View File

@@ -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<typeof configSchema>;
export const defaultConfig: Config = {
CNB_API_KEY: '',
CNB_COOKIE: '',
CNB_CORS_URL: 'https://cors.kevisual.cn',
AI_BASE_URL: '',
AI_MODEL: '',
AI_API_KEY: ''
};

43
src/app/page.tsx Normal file
View File

@@ -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 <div>Home Page</div>
}
export default Home;

View File

@@ -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 <div>Home Page</div>
}

View File

@@ -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)

9
src/routes/config.tsx Normal file
View File

@@ -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 <Home />
}

View File

@@ -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,
})

17
test/ai.ts Normal file
View File

@@ -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