diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e9c6441 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md + +本指南为在此仓库中工作的 AI 编码代理提供关键信息。 + +## 项目结构 + +``` +src/ +├── components/ui/ # shadcn/ui 组件(Base UI 基础组件) +├── lib/ # 工具函数(cn() 函数用于 className 合并) +├── modules/ # 应用模块(query client、basename) +├── pages/ # 页面组件(默认导出) +├── routes/ # TanStack Router 基于文件的路由 +├── styles/ # 全局样式、主题 CSS +└── main.tsx # 应用入口 +``` + + +## 代码风格指南 + +### 模块目录结构 + +每个新模块(如 `page-app`)应遵循以下结构: + +``` +pages/page-app/ +├── components/ # 模块专属组件 +├── store/ # 模块状态管理 +└── module/ # 模块功能函数 +``` + +### 状态和数据获取 + +- **Zustand** 用于全局状态管理 +- **@kevisual/query** 用于数据获取(QueryClient 实例位于 `src/modules/query.ts`) +- **React Hook Form** 用于表单管理 + +## 核心依赖 + +- **@base-ui/react**: Headless UI 基础组件 +- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由 +- **class-variance-authority**: 基于变体的样式系统 +- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数 +- **lucide-react**: 图标库 +- **react-hook-form**: 表单处理 +- **sonner**: Toast 通知 +- **zustand**: 状态管理 +- **tailwindcss v4**: 使用 @tailwindcss/vite 插件进行样式处理 \ No newline at end of file diff --git a/package.json b/package.json index cb626fd..41802e9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "vite-react", + "name": "@kevisual/cnb-center", "private": true, "version": "0.0.1", "type": "module", @@ -21,7 +21,7 @@ "@ai-sdk/openai": "^3.0.26", "@ai-sdk/openai-compatible": "^2.0.28", "@base-ui/react": "^1.1.0", - "@kevisual/cnb": "^0.0.20", + "@kevisual/cnb": "^0.0.22", "@kevisual/context": "^0.0.4", "@kevisual/router": "0.0.70", "@tanstack/react-router": "^1.158.4", @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.19", "es-toolkit": "^1.44.0", + "idb-keyval": "^6.2.2", "lucide-react": "^0.563.0", "nanoid": "^5.1.6", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cfd925..fe80a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^1.1.0 version: 1.1.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@kevisual/cnb': - specifier: ^0.0.20 - version: 0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1) + specifier: ^0.0.22 + version: 0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2) '@kevisual/context': specifier: ^0.0.4 version: 0.0.4 @@ -47,6 +47,9 @@ importers: es-toolkit: specifier: ^1.44.0 version: 1.44.0 + idb-keyval: + specifier: ^6.2.2 + version: 6.2.2 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) @@ -638,8 +641,8 @@ packages: '@kevisual/cache@0.0.2': resolution: {integrity: sha512-2Cl5KF2Gi27uLfhO6CdTMFnRzx9vYnqevAo7d9ab3rOaqTgF8tLeAXglXyRbaWW3WUbHU2XaOb4r98uUsqIQQw==} - '@kevisual/cnb@0.0.20': - resolution: {integrity: sha512-3ODGAT8vEnU90X/6SUeqMK1ZJCcvyn44bMsC7Joz0kvDKhntstbf/nZIm5TRhngvPEcOPyc+KROchTweC/qcNA==} + '@kevisual/cnb@0.0.22': + resolution: {integrity: sha512-KX8oSmmaHnT4qqCfAoQoHZbkcohUVSK7LfdsEKTlItrE77rPyZcvD+APByroxH4FMQ80ItRW9tQlxBO8iRlrIw==} '@kevisual/context@0.0.4': resolution: {integrity: sha512-HJeLeZQLU+7tCluSfOyvkgKLs0HjCZrdJlZgEgKRSa8XTwZfMAUt6J7qZTbrZAHBlPtX68EPu/PI8JMCeu3WAQ==} @@ -1490,6 +1493,9 @@ packages: idb-keyval@6.2.1: resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} @@ -2730,14 +2736,14 @@ snapshots: - tslib - typescript - '@kevisual/cnb@0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1)': + '@kevisual/cnb@0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2)': dependencies: '@kevisual/query': 0.0.40 '@kevisual/router': 0.0.70 '@kevisual/use-config': 1.0.30(dotenv@17.2.4) es-toolkit: 1.44.0 nanoid: 5.1.6 - unstorage: 1.17.4(idb-keyval@6.2.1) + unstorage: 1.17.4(idb-keyval@6.2.2) ws: '@kevisual/ws@8.19.0' zod: 4.3.6 transitivePeerDependencies: @@ -3532,6 +3538,8 @@ snapshots: idb-keyval@6.2.1: {} + idb-keyval@6.2.2: {} + immer@10.1.1: optional: true @@ -3996,7 +4004,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.4(idb-keyval@6.2.1): + unstorage@1.17.4(idb-keyval@6.2.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -4007,7 +4015,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.3 optionalDependencies: - idb-keyval: 6.2.1 + idb-keyval: 6.2.2 update-browserslist-db@1.1.1(browserslist@4.24.2): dependencies: diff --git a/src/agents/app.ts b/src/agents/app.ts new file mode 100644 index 0000000..1a678ea --- /dev/null +++ b/src/agents/app.ts @@ -0,0 +1,21 @@ +import { QueryRouterServer } from '@kevisual/router/browser' + +import { useContextKey } from '@kevisual/context' +import { useConfigStore } from '@/app/config/store' +import { CNB } from '@kevisual/cnb' + +export const app = useContextKey('router', new QueryRouterServer()) + +export const cnb: CNB = useContextKey('cnb', () => { + const state = useConfigStore.getState() + const config = state.config || {} + const cors: any = {} + if (config.ENABLE_CORS) { + cors.baseUrl = config.CNB_CORS_URL || 'https://cors.kevisual.cn' + } + return new CNB({ + token: config.CNB_API_KEY, + cookie: config.CNB_COOKIE, + cors + }) +}) \ No newline at end of file diff --git a/src/app/ai/page.tsx b/src/app/ai/page.tsx new file mode 100644 index 0000000..1e04528 --- /dev/null +++ b/src/app/ai/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/app/config/page.tsx b/src/app/config/page.tsx index 3ea7c29..74e642c 100644 --- a/src/app/config/page.tsx +++ b/src/app/config/page.tsx @@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { configSchema } from './store/schema'; +import { toast } from 'sonner'; export const ConfigPage = () => { const { config, setConfig, resetConfig } = useConfigStore(); @@ -13,8 +14,10 @@ export const ConfigPage = () => { e.preventDefault(); const result = configSchema.safeParse(config); if (result.success) { - console.log('配置已保存:', config); - // 可以在此处添加 toast 通知 + toast.success('配置已保存') + setTimeout(() => { + location.reload() + }, 400) } else { console.error('验证错误:', result.error.format()); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 4ac0b5b..9f2c057 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,43 +1,3 @@ -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) -} +import App from './repo/page' -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 +export default App; \ No newline at end of file diff --git a/src/app/repo/components/RepoCard.tsx b/src/app/repo/components/RepoCard.tsx new file mode 100644 index 0000000..7706679 --- /dev/null +++ b/src/app/repo/components/RepoCard.tsx @@ -0,0 +1,217 @@ +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card } from '@/components/ui/card' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw } from 'lucide-react' +import { useRepoStore } from '../store' +import { useMemo, useState } from 'react' +import { myOrgs } from '../store/build' + +interface RepoCardProps { + repo: any + onStartWorkspace: (repo: any) => void + onEdit: (repo: any) => void + onIssue: (repo: any) => void + onSettings: (repo: any) => void + onDelete: (repo: any) => void + onSync?: (repo: any) => void +} + +export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings, onDelete, onSync }: RepoCardProps) { + const [deletePopoverOpen, setDeletePopoverOpen] = useState(false) + const { workspaceList, getWorkspaceDetail } = useRepoStore(); + const workspace = useMemo(() => { + return workspaceList.find(ws => ws.slug === repo.path) + }, [workspaceList, repo.path]) + const isWorkspaceActive = !!workspace + const owner = repo.path.split('/')[0] + const isMine = myOrgs.includes(owner) + return ( + <> + +
+
+
+ + {repo.path} + + {isWorkspaceActive && ( + + + + + )} +
+
+ + + onStartWorkspace(repo)} + className="h-8 w-8 p-0 border-neutral-200 hover:border-neutral-900 hover:bg-neutral-900 hover:text-white transition-all cursor-pointer" + > + + + } + /> + +

启动工作区

+
+
+
+ + + + + } + /> + + onEdit(repo)} className="cursor-pointer"> + + 编辑 + + onIssue(repo)} className="cursor-pointer"> + + Issue + + onSettings(repo)} className="cursor-pointer"> + + 设置 + + { + e.preventDefault() + setDeletePopoverOpen(true) + }} + className="cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50" + > + + 删除 + + + + + +
+ + +
+
+

确认删除

+

+ 确定要删除仓库 {repo.path} 吗?此操作无法撤销。 +

+
+
+ + +
+
+
+ +
+
+ + {repo.topics && ( +
+ {repo.topics.split(',').map((topic: string, idx: number) => ( + + {topic.trim()} + + ))} +
+ )} + + {repo.site && ( + + 🔗 {repo.site} + + )} + + {repo.description && ( +

+ {repo.description} +

+ )} + +
+ + + {repo.star_count} + + + + {repo.fork_count} + + + + {repo.open_issue_count} + + {isWorkspaceActive && { + getWorkspaceDetail(workspace) + }}> + + 运行中 + } + {isMine && ( + onSync?.(repo)} + > + + 同步 + + )} +
+
+ + + ) +} diff --git a/src/app/repo/modules/EditRepoDialog.tsx b/src/app/repo/modules/EditRepoDialog.tsx new file mode 100644 index 0000000..233c113 --- /dev/null +++ b/src/app/repo/modules/EditRepoDialog.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { TagsInput } from '@/components/tags-input' +import { useRepoStore } from '../store' + +interface EditRepoDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + repo: { + id: string + path: string + description: string + site: string + topics: string + license: string + } | null +} + +interface FormData { + description: string + site: string + topics: string + license: string +} + +export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps) { + const { updateRepoInfo, getList } = useRepoStore() + const { register, handleSubmit, reset, setValue } = useForm() + const [tags, setTags] = useState([]) + + useEffect(() => { + if (repo) { + const topicsArray = repo.topics ? repo.topics.split(',').map(t => t.trim()).filter(Boolean) : [] + setTags(topicsArray) + reset({ + description: repo.description || '', + site: repo.site || '', + topics: repo.topics || '', + license: repo.license || '' + }) + } + }, [repo, reset]) + + const onSubmit = async (data: FormData) => { + if (!repo) return + + await updateRepoInfo({ + path: repo.path, + description: data.description?.trim() || '', + site: data.site?.trim() || '', + topics: tags.join(','), + license: data.license?.trim() || '', + }) + + await getList(true) + onOpenChange(false) + } + + if (!repo) return null + + return ( + + + + 编辑仓库信息 + {repo.path} + + +
+
+ +